agora inbox for pgsql-hackers@postgresql.org
help / color / mirror / Atom feed[PATCH 16/17] Teach the remaining frontend application about encryption.
912+ messages / 10 participants
[nested] [flat]
* [PATCH 16/17] Teach the remaining frontend application about encryption.
@ 2019-07-05 14:24 Antonin Houska <ah@cybertec.at>
0 siblings, 0 replies; 912+ messages in thread
From: Antonin Houska @ 2019-07-05 14:24 UTC (permalink / raw)
pg_checksums, pg_rewind, pg_waldump
---
src/bin/pg_checksums/Makefile | 9 +-
src/bin/pg_checksums/pg_checksums.c | 158 +++++++++++++++++++++++++++++++++---
src/bin/pg_rewind/Makefile | 9 +-
src/bin/pg_rewind/parsexlog.c | 9 ++
src/bin/pg_rewind/pg_rewind.c | 57 ++++++++++++-
src/bin/pg_waldump/Makefile | 9 +-
src/bin/pg_waldump/pg_waldump.c | 90 +++++++++++++++++---
7 files changed, 309 insertions(+), 32 deletions(-)
diff --git a/src/bin/pg_checksums/Makefile b/src/bin/pg_checksums/Makefile
index 278b7a0f2e..74e66ea0b7 100644
--- a/src/bin/pg_checksums/Makefile
+++ b/src/bin/pg_checksums/Makefile
@@ -15,13 +15,19 @@ subdir = src/bin/pg_checksums
top_builddir = ../../..
include $(top_builddir)/src/Makefile.global
-OBJS= pg_checksums.o $(WIN32RES)
+OBJS= encryption.o pg_checksums.o $(WIN32RES)
+
+override CPPFLAGS := -DFRONTEND $(CPPFLAGS)
+LDFLAGS_INTERNAL += -L$(top_builddir)/src/fe_utils -lpgfeutils $(libpq_pgport)
all: pg_checksums
pg_checksums: $(OBJS) | submake-libpgport
$(CC) $(CFLAGS) $^ $(LDFLAGS) $(LDFLAGS_EX) $(LIBS) -o $@$(X)
+encryption.c: % : $(top_srcdir)/src/backend/storage/file/%
+ rm -f $@ && $(LN_S) $< .
+
install: all installdirs
$(INSTALL_PROGRAM) pg_checksums$(X) '$(DESTDIR)$(bindir)/pg_checksums$(X)'
@@ -34,6 +40,7 @@ uninstall:
clean distclean maintainer-clean:
rm -f pg_checksums$(X) $(OBJS)
rm -rf tmp_check
+ rm -f encryption.c
check:
$(prove_check)
diff --git a/src/bin/pg_checksums/pg_checksums.c b/src/bin/pg_checksums/pg_checksums.c
index b591fcc864..587132faf9 100644
--- a/src/bin/pg_checksums/pg_checksums.c
+++ b/src/bin/pg_checksums/pg_checksums.c
@@ -20,15 +20,18 @@
#include <unistd.h>
#include "access/xlog_internal.h"
+#include "catalog/pg_tablespace_d.h"
#include "common/controldata_utils.h"
#include "common/file_perm.h"
#include "common/file_utils.h"
#include "common/logging.h"
+#include "fe_utils/encryption.h"
#include "getopt_long.h"
#include "pg_getopt.h"
#include "storage/bufpage.h"
#include "storage/checksum.h"
#include "storage/checksum_impl.h"
+#include "storage/encryption.h"
static int64 files = 0;
@@ -80,6 +83,10 @@ usage(void)
printf(_(" -c, --check check data checksums (default)\n"));
printf(_(" -d, --disable disable data checksums\n"));
printf(_(" -e, --enable enable data checksums\n"));
+#ifdef USE_ENCRYPTION
+ printf(_(" -K, --encryption-key-command=COMMAND\n"
+ " command that returns encryption key\n"));
+#endif /* USE_ENCRYPTION */
printf(_(" -f, --filenode=FILENODE check only relation with specified filenode\n"));
printf(_(" -N, --no-sync do not wait for changes to be written safely to disk\n"));
printf(_(" -P, --progress show progress information\n"));
@@ -167,7 +174,8 @@ skipfile(const char *fn)
}
static void
-scan_file(const char *fn, BlockNumber segmentno)
+scan_file(const char *fn, Oid relnode, BlockNumber segmentno,
+ ForkNumber forkno, Oid tbspace, Oid db)
{
PGAlignedBlock buf;
PageHeader header = (PageHeader) buf.data;
@@ -193,6 +201,7 @@ scan_file(const char *fn, BlockNumber segmentno)
{
uint16 csum;
int r = read(f, buf.data, BLCKSZ);
+ char tweak[TWEAK_SIZE];
if (r == 0)
break;
@@ -204,6 +213,18 @@ scan_file(const char *fn, BlockNumber segmentno)
}
blocks++;
+ if (data_encrypted)
+ {
+ RelFileNode node;
+
+ node.spcNode = tbspace;
+ node.dbNode = db;
+ node.relNode = relnode;
+
+ mdtweak(tweak, &node, forkno, blockno);
+ decrypt_block(buf.data, buf.data, BLCKSZ, tweak, false);
+ }
+
/* New pages have no checksum yet */
if (PageIsNew(header))
continue;
@@ -232,6 +253,9 @@ scan_file(const char *fn, BlockNumber segmentno)
exit(1);
}
+ if (data_encrypted)
+ encrypt_block(buf.data, buf.data, BLCKSZ, tweak, false);
+
/* Write block with checksum */
if (write(f, buf.data, BLCKSZ) != BLCKSZ)
{
@@ -262,9 +286,13 @@ scan_file(const char *fn, BlockNumber segmentno)
* all the items which have checksums is computed and returned back
* to the caller without operating on the files. This is used to compile
* the total size of the data directory for progress reports.
+ *
+ * If db is a valid pointer, *db contains database OID. If it's NULL, the
+ * database OID needs to be recognized, possibly by recursive call.
*/
static int64
-scan_directory(const char *basedir, const char *subdir, bool sizeonly)
+scan_directory(const char *basedir, const char *subdir, bool sizeonly,
+ Oid tbspace, Oid *db)
{
int64 dirsize = 0;
char path[MAXPGPATH];
@@ -282,6 +310,9 @@ scan_directory(const char *basedir, const char *subdir, bool sizeonly)
{
char fn[MAXPGPATH];
struct stat st;
+ bool tbsp_identified = false;
+ Oid database;
+ bool db_identified = false;
if (strcmp(de->d_name, ".") == 0 ||
strcmp(de->d_name, "..") == 0)
@@ -305,12 +336,58 @@ scan_directory(const char *basedir, const char *subdir, bool sizeonly)
pg_log_error("could not stat file \"%s\": %m", fn);
exit(1);
}
+
+ if (tbspace != InvalidOid && db == NULL)
+ {
+ /*
+ * If tablespace is passed by caller or identified by upper call
+ * of this function, and if caller could not identify the database
+ * OID, try to do it now.
+ */
+ if (strcmp(de->d_name, TABLESPACE_VERSION_DIRECTORY) == 0)
+ {
+ /*
+ * Major-version-specific tablespace subdirectory needs no
+ * special attention, we'll recurse into it below.
+ */
+ }
+ else
+ {
+ errno = 0;
+ database = strtol(de->d_name, NULL, 10);
+ if (errno != 0 || database == InvalidOid)
+ {
+ fprintf(stderr, _("%s: invalid database oid \"%s\"\n"),
+ progname, de->d_name);
+ exit(1);
+ }
+ db_identified = true;
+ }
+ }
+ else if (tbspace == InvalidOid)
+ {
+ /*
+ * This entry should be a direct subdirectory of pg_tblspc, so the
+ * name is supposedly tablespace oid.
+ */
+ errno = 0;
+ tbspace = strtol(de->d_name, NULL, 10);
+ if (errno != 0 || tbspace == InvalidOid)
+ {
+ fprintf(stderr, _("%s: invalid tablespace oid \"%s\"\n"),
+ progname, de->d_name);
+ exit(1);
+ }
+ tbsp_identified = true;
+ }
+
if (S_ISREG(st.st_mode))
{
char fnonly[MAXPGPATH];
char *forkpath,
*segmentpath;
BlockNumber segmentno = 0;
+ ForkNumber forkno;
if (skipfile(de->d_name))
continue;
@@ -337,10 +414,16 @@ scan_directory(const char *basedir, const char *subdir, bool sizeonly)
forkpath = strchr(fnonly, '_');
if (forkpath != NULL)
+ {
*forkpath++ = '\0';
+ forkno = forkname_to_number(forkpath);
+ }
+ else
+ forkno = MAIN_FORKNUM;
+
if (only_filenode && strcmp(only_filenode, fnonly) != 0)
- /* filenode not to be included */
+ /* Relfilenode not to be included */
continue;
dirsize += st.st_size;
@@ -350,14 +433,31 @@ scan_directory(const char *basedir, const char *subdir, bool sizeonly)
* the items in the data folder.
*/
if (!sizeonly)
- scan_file(fn, segmentno);
+ {
+ Oid relnode = atoi(fnonly);
+
+ scan_file(fn, relnode, segmentno, forkno, tbspace, *db);
+ }
}
#ifndef WIN32
else if (S_ISDIR(st.st_mode) || S_ISLNK(st.st_mode))
#else
else if (S_ISDIR(st.st_mode) || pgwin32_is_junction(fn))
#endif
- dirsize += scan_directory(path, de->d_name, sizeonly);
+
+ /*
+ * If database OID is not passed by caller, use the one we
+ * identified ourselves.
+ */
+ dirsize = scan_directory(path, de->d_name, sizeonly, tbspace,
+ db_identified ? &database : db);
+
+ /*
+ * If tablespace is not passed by caller, forget what we found out so
+ * that next directory entry is examined in the same way.
+ */
+ if (tbsp_identified)
+ tbspace = InvalidOid;
}
closedir(dir);
return dirsize;
@@ -371,6 +471,9 @@ main(int argc, char *argv[])
{"pgdata", required_argument, NULL, 'D'},
{"disable", no_argument, NULL, 'd'},
{"enable", no_argument, NULL, 'e'},
+#ifdef USE_ENCRYPTION
+ {"encryption-key-command", required_argument, NULL, 'K'},
+#endif /* USE_ENCRYPTION */
{"filenode", required_argument, NULL, 'f'},
{"no-sync", no_argument, NULL, 'N'},
{"progress", no_argument, NULL, 'P'},
@@ -401,7 +504,7 @@ main(int argc, char *argv[])
}
}
- while ((c = getopt_long(argc, argv, "cD:deNPf:v", long_options, &option_index)) != -1)
+ while ((c = getopt_long(argc, argv, "cD:deNK:Pf:v", long_options, &option_index)) != -1)
{
switch (c)
{
@@ -422,6 +525,12 @@ main(int argc, char *argv[])
}
only_filenode = pstrdup(optarg);
break;
+#ifdef USE_ENCRYPTION
+ case 'K':
+ encryption_key_command = pg_strdup(optarg);
+ data_encrypted = true;
+ break;
+#endif /* USE_ENCRYPTION */
case 'N':
do_sync = false;
break;
@@ -483,6 +592,24 @@ main(int argc, char *argv[])
exit(1);
}
+ if (ControlFile->data_cipher > PG_CIPHER_NONE)
+ {
+ if (encryption_key_command == NULL)
+ {
+#ifdef USE_ENCRYPTION
+ fprintf(stderr, _("%s: please specify command to retrieve encryption key\n"),
+ progname);
+#else
+ fprintf(stderr, _("%s: compile postgres with --with-openssl to use encryption\n"),
+ progname);
+#endif /* USE_ENCRYPTION */
+ exit(1);
+ }
+
+ run_encryption_key_command(encryption_key, DataDir);
+ setup_encryption();
+ }
+
if (ControlFile->pg_control_version != PG_CONTROL_VERSION)
{
pg_log_error("cluster is not compatible with this version of pg_checksums");
@@ -533,6 +660,8 @@ main(int argc, char *argv[])
/* Operate on all files if checking or enabling checksums */
if (mode == PG_MODE_CHECK || mode == PG_MODE_ENABLE)
{
+ Oid db_shared = 0;
+
/*
* If progress status information is requested, we need to scan the
* directory tree twice: once to know how much total data needs to be
@@ -540,14 +669,19 @@ main(int argc, char *argv[])
*/
if (showprogress)
{
- total_size = scan_directory(DataDir, "global", true);
- total_size += scan_directory(DataDir, "base", true);
- total_size += scan_directory(DataDir, "pg_tblspc", true);
+ total_size = scan_directory(DataDir, "global", true,
+ GLOBALTABLESPACE_OID, &db_shared);
+ total_size += scan_directory(DataDir, "base", true,
+ DEFAULTTABLESPACE_OID, NULL);
+ total_size += scan_directory(DataDir, "pg_tblspc", true,
+ InvalidOid, NULL);
}
- (void) scan_directory(DataDir, "global", false);
- (void) scan_directory(DataDir, "base", false);
- (void) scan_directory(DataDir, "pg_tblspc", false);
+ (void) scan_directory(DataDir, "global", false, GLOBALTABLESPACE_OID,
+ &db_shared);
+ (void) scan_directory(DataDir, "base", false, DEFAULTTABLESPACE_OID,
+ NULL);
+ (void) scan_directory(DataDir, "pg_tblspc", false, InvalidOid, NULL);
if (showprogress)
{
diff --git a/src/bin/pg_rewind/Makefile b/src/bin/pg_rewind/Makefile
index 859d3abc41..e41d2035f4 100644
--- a/src/bin/pg_rewind/Makefile
+++ b/src/bin/pg_rewind/Makefile
@@ -16,10 +16,10 @@ top_builddir = ../../..
include $(top_builddir)/src/Makefile.global
override CPPFLAGS := -I$(libpq_srcdir) -DFRONTEND $(CPPFLAGS)
-LDFLAGS_INTERNAL += $(libpq_pgport)
+LDFLAGS_INTERNAL += -L$(top_builddir)/src/fe_utils -lpgfeutils $(libpq_pgport)
OBJS = pg_rewind.o parsexlog.o xlogreader.o datapagemap.o timeline.o \
- fetch.o file_ops.o copy_fetch.o libpq_fetch.o filemap.o \
+ fetch.o file_ops.o copy_fetch.o libpq_fetch.o filemap.o encryption.o\
$(WIN32RES)
EXTRA_CLEAN = xlogreader.c
@@ -32,6 +32,9 @@ pg_rewind: $(OBJS) | submake-libpq submake-libpgport
xlogreader.c: % : $(top_srcdir)/src/backend/access/transam/%
rm -f $@ && $(LN_S) $< .
+encryption.c: % : $(top_srcdir)/src/backend/storage/file/%
+ rm -f $@ && $(LN_S) $< .
+
install: all installdirs
$(INSTALL_PROGRAM) pg_rewind$(X) '$(DESTDIR)$(bindir)/pg_rewind$(X)'
@@ -42,7 +45,7 @@ uninstall:
rm -f '$(DESTDIR)$(bindir)/pg_rewind$(X)'
clean distclean maintainer-clean:
- rm -f pg_rewind$(X) $(OBJS) xlogreader.c
+ rm -f pg_rewind$(X) $(OBJS) xlogreader.c encryption.c
rm -rf tmp_check
check:
diff --git a/src/bin/pg_rewind/parsexlog.c b/src/bin/pg_rewind/parsexlog.c
index 287af60c4e..0624c4c5ef 100644
--- a/src/bin/pg_rewind/parsexlog.c
+++ b/src/bin/pg_rewind/parsexlog.c
@@ -320,6 +320,15 @@ SimpleXLogPageRead(XLogReaderState *xlogreader, XLogRecPtr targetPagePtr,
return -1;
}
+ if (data_encrypted)
+ {
+ char tweak[TWEAK_SIZE];
+
+ XLogEncryptionTweak(tweak, targetHistory[private->tliIndex].tli,
+ xlogreadsegno, targetPageOff);
+ decrypt_block(readBuf, readBuf, XLOG_BLCKSZ, tweak, true);
+ }
+
Assert(targetSegNo == xlogreadsegno);
*pageTLI = targetHistory[private->tliIndex].tli;
diff --git a/src/bin/pg_rewind/pg_rewind.c b/src/bin/pg_rewind/pg_rewind.c
index d378053de4..58c192adad 100644
--- a/src/bin/pg_rewind/pg_rewind.c
+++ b/src/bin/pg_rewind/pg_rewind.c
@@ -27,8 +27,10 @@
#include "common/file_perm.h"
#include "common/file_utils.h"
#include "common/restricted_token.h"
+#include "fe_utils/encryption.h"
#include "getopt_long.h"
#include "storage/bufpage.h"
+#include "storage/encryption.h"
static void usage(const char *progname);
@@ -75,6 +77,10 @@ usage(const char *progname)
printf(_(" -D, --target-pgdata=DIRECTORY existing data directory to modify\n"));
printf(_(" --source-pgdata=DIRECTORY source data directory to synchronize with\n"));
printf(_(" --source-server=CONNSTR source server to synchronize with\n"));
+#ifdef USE_ENCRYPTION
+ printf(_(" -K, --encryption-key-command=COMMAND\n"
+ " command that returns encryption key\n"));
+#endif /* USE_ENCRYPTION */
printf(_(" -n, --dry-run stop before modifying anything\n"));
printf(_(" -N, --no-sync do not wait for changes to be written\n"
" safely to disk\n"));
@@ -99,6 +105,9 @@ main(int argc, char **argv)
{"no-sync", no_argument, NULL, 'N'},
{"progress", no_argument, NULL, 'P'},
{"debug", no_argument, NULL, 3},
+#ifdef USE_ENCRYPTION
+ {"encryption-key-command", required_argument, NULL, 'K'},
+#endif /* USE_ENCRYPTION */
{NULL, 0, NULL, 0}
};
int option_index;
@@ -134,7 +143,7 @@ main(int argc, char **argv)
}
}
- while ((c = getopt_long(argc, argv, "D:nNP", long_options, &option_index)) != -1)
+ while ((c = getopt_long(argc, argv, "D:K:nNP", long_options, &option_index)) != -1)
{
switch (c)
{
@@ -169,6 +178,12 @@ main(int argc, char **argv)
case 2: /* --source-server */
connstr_source = pg_strdup(optarg);
break;
+#ifdef USE_ENCRYPTION
+ case 4: /* --encryption-key-command */
+ case 'K':
+ encryption_key_command = strdup(optarg);
+ break;
+#endif /* USE_ENCRYPTION */
}
}
@@ -248,6 +263,28 @@ main(int argc, char **argv)
sanityChecks();
/*
+ * Setup encryption if it's obvious that we'll have to deal with encrypted
+ * XLOG.
+ */
+ if (ControlFile_target.data_cipher > PG_CIPHER_NONE)
+ {
+ if (encryption_key_command == NULL)
+ {
+ pg_log_error("-K option must be passed for encrypted cluster");
+ exit(EXIT_FAILURE);
+ }
+
+ /*
+ * It should not matter whether we pass the source or target data
+ * directory. It should have been checked earlier that both clusters
+ * are encrypted using the same key.
+ */
+ run_encryption_key_command(encryption_key, datadir_source);
+ setup_encryption();
+ data_encrypted = true;
+ }
+
+ /*
* If both clusters are already on the same timeline, there's nothing to
* do.
*/
@@ -446,6 +483,24 @@ sanityChecks(void)
ControlFile_source.state != DB_SHUTDOWNED &&
ControlFile_source.state != DB_SHUTDOWNED_IN_RECOVERY)
pg_fatal("source data directory must be shut down cleanly");
+
+ /*
+ * Since slave receives XLOG stream encrypted by master, handling
+ * differently encrypted clusters is not the typical use case for
+ * pg_rewind. Yet we should check the encryption.
+ */
+ if (ControlFile_source.data_cipher > PG_CIPHER_NONE ||
+ ControlFile_target.data_cipher > PG_CIPHER_NONE)
+ {
+ if (ControlFile_source.data_cipher !=
+ ControlFile_target.data_cipher)
+ pg_fatal("source and target server must be both unencrypted or both encrypted\n");
+
+ if (memcmp(ControlFile_source.encryption_verification,
+ ControlFile_target.encryption_verification,
+ ENCRYPTION_SAMPLE_SIZE))
+ pg_fatal("both source and target server must use the same encryption key");
+ }
}
/*
diff --git a/src/bin/pg_waldump/Makefile b/src/bin/pg_waldump/Makefile
index 135979cef3..9eb1c2f01d 100644
--- a/src/bin/pg_waldump/Makefile
+++ b/src/bin/pg_waldump/Makefile
@@ -7,10 +7,11 @@ subdir = src/bin/pg_waldump
top_builddir = ../../..
include $(top_builddir)/src/Makefile.global
-OBJS = pg_waldump.o compat.o xlogreader.o rmgrdesc.o \
+OBJS = pg_waldump.o compat.o xlogreader.o rmgrdesc.o encryption.o \
$(RMGRDESCOBJS) $(WIN32RES)
override CPPFLAGS := -DFRONTEND $(CPPFLAGS)
+LDFLAGS_INTERNAL += -L$(top_builddir)/src/fe_utils -lpgfeutils $(libpq_pgport)
RMGRDESCSOURCES = $(sort $(notdir $(wildcard $(top_srcdir)/src/backend/access/rmgrdesc/*desc.c)))
RMGRDESCOBJS = $(patsubst %.c,%.o,$(RMGRDESCSOURCES))
@@ -27,6 +28,9 @@ xlogreader.c: % : $(top_srcdir)/src/backend/access/transam/%
$(RMGRDESCSOURCES): % : $(top_srcdir)/src/backend/access/rmgrdesc/%
rm -f $@ && $(LN_S) $< .
+encryption.c: % : $(top_srcdir)/src/backend/storage/file/%
+ rm -f $@ && $(LN_S) $< .
+
install: all installdirs
$(INSTALL_PROGRAM) pg_waldump$(X) '$(DESTDIR)$(bindir)/pg_waldump$(X)'
@@ -37,7 +41,8 @@ uninstall:
rm -f '$(DESTDIR)$(bindir)/pg_waldump$(X)'
clean distclean maintainer-clean:
- rm -f pg_waldump$(X) $(OBJS) $(RMGRDESCSOURCES) xlogreader.c
+ rm -f pg_waldump$(X) $(OBJS) $(RMGRDESCSOURCES) xlogreader.c \
+encryption.c
rm -rf tmp_check
check:
diff --git a/src/bin/pg_waldump/pg_waldump.c b/src/bin/pg_waldump/pg_waldump.c
index b95d467805..4df84efb01 100644
--- a/src/bin/pg_waldump/pg_waldump.c
+++ b/src/bin/pg_waldump/pg_waldump.c
@@ -22,6 +22,8 @@
#include "access/transam.h"
#include "common/fe_memutils.h"
#include "common/logging.h"
+#include "fe_utils/encryption.h"
+#include "storage/encryption.h"
#include "getopt_long.h"
#include "rmgrdesc.h"
@@ -194,27 +196,59 @@ search_directory(const char *directory, const char *fname)
PGAlignedXLogBlock buf;
int r;
- r = read(fd, buf.data, XLOG_BLCKSZ);
- if (r == XLOG_BLCKSZ)
+ if (data_encrypted)
{
- XLogLongPageHeader longhdr = (XLogLongPageHeader) buf.data;
-
- WalSegSz = longhdr->xlp_seg_size;
+ /*
+ * Segment size affects calculation of segNo and thus also the
+ * encryption tweak, so we cannot get the size from the header
+ * until the page is decrypted. Here we need to take more
+ * expensive approach and really check the file size.
+ */
+ WalSegSz = (int) lseek(fd, 0, SEEK_END);
+ if (WalSegSz <= 0)
+ fatal_error("Could not determine size of WAL segment \"%s\"", fname);
+ /*
+ * Verification of the file size is the only useful thing we can
+ * do. If anything else is wrong, the XLOG reader should find out
+ * after decryption.
+ */
if (!IsValidWalSegSize(WalSegSz))
- fatal_error(ngettext("WAL segment size must be a power of two between 1 MB and 1 GB, but the WAL file \"%s\" header specifies %d byte",
- "WAL segment size must be a power of two between 1 MB and 1 GB, but the WAL file \"%s\" header specifies %d bytes",
+ fatal_error(ngettext("WAL segment size must be a power of two between 1 MB and 1 GB, but size of the WAL file \"%s\" is %d byte",
+ "WAL segment size must be a power of two between 1 MB and 1 GB, but size of the WAL file \"%s\" is %d bytes",
WalSegSz),
fname, WalSegSz);
}
else
{
- if (errno != 0)
- fatal_error("could not read file \"%s\": %s",
- fname, strerror(errno));
+ r = read(fd, buf.data, XLOG_BLCKSZ);
+ if (r == XLOG_BLCKSZ)
+ {
+ XLogLongPageHeader longhdr;
+
+ longhdr = (XLogLongPageHeader) buf.data;
+
+ if (longhdr->std.xlp_magic != XLOG_PAGE_MAGIC)
+ fatal_error(gettext("WAL segment \"%s\" has an incorrect magic number. If it's encrypted, use the -K option to pass encryption credentials"),
+ fname);
+
+ WalSegSz = longhdr->xlp_seg_size;
+
+ if (!IsValidWalSegSize(WalSegSz))
+ fatal_error(ngettext("WAL segment size must be a power of two between 1 MB and 1 GB, but the WAL file \"%s\" header specifies %d byte",
+ "WAL segment size must be a power of two between 1 MB and 1 GB, but the WAL file \"%s\" header specifies %d bytes",
+ WalSegSz),
+ fname, WalSegSz);
+ }
else
- fatal_error("could not read file \"%s\": read %d of %zu",
- fname, r, (Size) XLOG_BLCKSZ);
+ {
+ if (errno != 0)
+ fatal_error("could not read file \"%s\": %s",
+ fname, strerror(errno));
+ else
+ fatal_error("could not read file \"%s\": read %d of %zu",
+ fname, r, (Size) XLOG_BLCKSZ);
+ }
}
close(fd);
return true;
@@ -444,6 +478,19 @@ XLogDumpReadPage(XLogReaderState *state, XLogRecPtr targetPagePtr, int reqLen,
XLogDumpXLogRead(private->inpath, private->timeline, targetPagePtr,
readBuff, count);
+ if (data_encrypted)
+ {
+ char tweak[TWEAK_SIZE];
+ XLogSegNo readSegNo;
+ uint32 readSegOff;
+
+ XLByteToSeg(targetPagePtr, readSegNo, WalSegSz);
+ readSegOff = targetPagePtr % WalSegSz;
+
+ XLogEncryptionTweak(tweak, private->timeline, readSegNo, readSegOff);
+ decrypt_block(readBuff, readBuff, count, tweak, true);
+ }
+
return count;
}
@@ -792,6 +839,10 @@ usage(void)
printf(_(" -b, --bkp-details output detailed information about backup blocks\n"));
printf(_(" -e, --end=RECPTR stop reading at WAL location RECPTR\n"));
printf(_(" -f, --follow keep retrying after reaching end of WAL\n"));
+#ifdef USE_ENCRYPTION
+ printf(_(" -K, --encryption-key-command=COMMAND\n"
+ " command that returns encryption key\n"));
+#endif /* USE_ENCRYPTION */
printf(_(" -n, --limit=N number of records to display\n"));
printf(_(" -p, --path=PATH directory in which to find log segment files or a\n"
" directory with a ./pg_wal that contains such files\n"
@@ -827,6 +878,7 @@ main(int argc, char **argv)
{"end", required_argument, NULL, 'e'},
{"follow", no_argument, NULL, 'f'},
{"help", no_argument, NULL, '?'},
+ {"encryption-key-command", required_argument, NULL, 'K'},
{"limit", required_argument, NULL, 'n'},
{"path", required_argument, NULL, 'p'},
{"rmgr", required_argument, NULL, 'r'},
@@ -884,7 +936,7 @@ main(int argc, char **argv)
goto bad_argument;
}
- while ((option = getopt_long(argc, argv, "be:fn:p:r:s:t:x:z",
+ while ((option = getopt_long(argc, argv, "be:fK:n:p:r:s:t:x:z",
long_options, &optindex)) != -1)
{
switch (option)
@@ -904,6 +956,12 @@ main(int argc, char **argv)
case 'f':
config.follow = true;
break;
+#ifdef USE_ENCRYPTION
+ case 'K':
+ encryption_key_command = pg_strdup(optarg);
+ data_encrypted = true;
+ break;
+#endif /* USE_ENCRYPTION */
case 'n':
if (sscanf(optarg, "%d", &config.stop_after_records) != 1)
{
@@ -1099,6 +1157,12 @@ main(int argc, char **argv)
/* done with argument parsing, do the actual work */
+ if (data_encrypted)
+ {
+ run_encryption_key_command(encryption_key, NULL);
+ setup_encryption();
+ }
+
/* we have everything we need, start reading */
xlogreader_state = XLogReaderAllocate(WalSegSz, XLogDumpReadPage,
&private);
--
2.13.7
--=-=-=
Content-Type: text/x-diff
Content-Disposition: attachment;
filename=v04-0017-User-and-development-documentation.patch
^ permalink raw reply [nested|flat] 912+ messages in thread
* Proposal: Conflict log history table for Logical Replication
@ 2025-08-05 12:24 Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 2 replies; 912+ messages in thread
From: Dilip Kumar @ 2025-08-05 12:24 UTC (permalink / raw)
To: PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Currently we log conflicts to the server's log file and updates, this
approach has limitations, 1) Difficult to query and analyze, parsing
plain text log files for conflict details is inefficient. 2) Lack of
structured data, key conflict attributes (table, operation, old/new
data, LSN, etc.) are not readily available in a structured, queryable
format. 3) Difficult for external monitoring tools or custom
resolution scripts to consume conflict data directly.
This proposal aims to address these limitations by introducing a
conflict log history table, providing a structured, and queryable
record of all logical replication conflicts. This should be a
configurable option whether to log into the conflict log history
table, server logs or both.
This proposal has two main design questions:
===================================
1. How do we store conflicting tuples from different tables?
Using a JSON column to store the row data seems like the most flexible
solution, as it can accommodate different table schemas.
2. Should this be a system table or a user table?
a) System Table: Storing this in a system catalog is simple, but
catalogs aren't designed for ever-growing data. While pg_large_object
is an exception, this is not what we generally do IMHO.
b) User Table: This offers more flexibility. We could allow a user to
specify the table name during CREATE SUBSCRIPTION. Then we choose to
either create the table internally or let the user create the table
with a predefined schema.
A potential drawback is that a user might drop or alter the table.
However, we could mitigate this risk by simply logging a WARNING if
the table is configured but an insertion fails.
I am currently working on a POC patch for the same, but will post that
once we have some thoughts on design choices.
Schema for the conflict log history table may look like this, although
there is a room for discussion on this.
Note: I think these fields are self explanatory so I haven't
explained them here.
conflict_log_table (
logid SERIAL PRIMARY KEY,
subid OID,
schema_id OID,
table_id OID,
conflict_type TEXT NOT NULL,
operation_type TEXT NOT NULL,
replication_origin TEXT,
remote_commit_ts TIMESTAMPTZ,
local_commit_ts TIMESTAMPTZ,
ri_key JSON,
remote_tuple JSON,
local_tuple JSON,
);
Credit: Thanks to Amit Kapila for discussing this offlist and
providing some valuable suggestions.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-08-07 06:55 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: shveta malik @ 2025-08-07 06:55 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Tue, Aug 5, 2025 at 5:54 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> Currently we log conflicts to the server's log file and updates, this
> approach has limitations, 1) Difficult to query and analyze, parsing
> plain text log files for conflict details is inefficient. 2) Lack of
> structured data, key conflict attributes (table, operation, old/new
> data, LSN, etc.) are not readily available in a structured, queryable
> format. 3) Difficult for external monitoring tools or custom
> resolution scripts to consume conflict data directly.
>
> This proposal aims to address these limitations by introducing a
> conflict log history table, providing a structured, and queryable
> record of all logical replication conflicts. This should be a
> configurable option whether to log into the conflict log history
> table, server logs or both.
>
+1 for the idea.
> This proposal has two main design questions:
> ===================================
>
> 1. How do we store conflicting tuples from different tables?
> Using a JSON column to store the row data seems like the most flexible
> solution, as it can accommodate different table schemas.
Yes, that is one option. I have not looked into details myself, but
you can also explore 'anyarray' used in pg_statistics to store 'Column
data values of the appropriate kind'.
> 2. Should this be a system table or a user table?
> a) System Table: Storing this in a system catalog is simple, but
> catalogs aren't designed for ever-growing data. While pg_large_object
> is an exception, this is not what we generally do IMHO.
> b) User Table: This offers more flexibility. We could allow a user to
> specify the table name during CREATE SUBSCRIPTION. Then we choose to
> either create the table internally or let the user create the table
> with a predefined schema.
>
> A potential drawback is that a user might drop or alter the table.
> However, we could mitigate this risk by simply logging a WARNING if
> the table is configured but an insertion fails.
I believe it makes more sense for this to be a catalog table rather
than a user table. I wanted to check if we already have a large
catalog table of this kind, and I think pg_statistic could be an
example of a sizable catalog table. To get a rough idea of how size
scales with data, I ran a quick experiment: I created 1000 tables,
each with 2 JSON columns, 1 text column, and 2 integer columns. Then,
I inserted 1000 rows into each table and ran ANALYZE to collect
statistics. Here’s what I observed on a fresh database before and
after:
Before:
pg_statistic row count: 412
Table size: ~256 kB
After:
pg_statistic row count: 6,412
Table size: ~5.3 MB
Although it isn’t an exact comparison, this gives us some insight into
how the statistics catalog table size grows with the number of rows.
It doesn’t seem excessively large with 6k rows, given the fact that
pg_statistic itself is a complex table having many 'anyarray'-type
columns.
That said, irrespective of what we decide, it would be ideal to offer
users an option for automatic purging, perhaps via a retention period
parameter like conflict_stats_retention_period (say default to 30
days), or a manual purge API such as purge_conflict_stats('older than
date'). I wasn’t able to find any such purge mechanism for PostgreSQL
stats tables, but Oracle does provide such purging options for some of
their statistics tables (not related to conflicts), see [1], [2].
And to manage it better, it could be range partitioned on timestamp.
> I am currently working on a POC patch for the same, but will post that
> once we have some thoughts on design choices.
>
> Schema for the conflict log history table may look like this, although
> there is a room for discussion on this.
>
> Note: I think these fields are self explanatory so I haven't
> explained them here.
>
> conflict_log_table (
> logid SERIAL PRIMARY KEY,
> subid OID,
> schema_id OID,
> table_id OID,
> conflict_type TEXT NOT NULL,
> operation_type TEXT NOT NULL,
I feel operation_type is not needed when we already have
conflict_type. The name of 'conflict_type' is enough to give us info
on operation-type.
> replication_origin TEXT,
> remote_commit_ts TIMESTAMPTZ,
> local_commit_ts TIMESTAMPTZ,
> ri_key JSON,
> remote_tuple JSON,
> local_tuple JSON,
> );
>
> Credit: Thanks to Amit Kapila for discussing this offlist and
> providing some valuable suggestions.
>
[1]
https://docs.oracle.com/en/database/oracle/oracle-database/21/arpls/DBMS_STATS.html#GUID-8E6413D5-F8...
[2]
https://docs.oracle.com/en/database/oracle/oracle-database/21/arpls/DBMS_STATS.html#GUID-A04AE1C0-5D...
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-08-07 08:13 shveta malik <shveta.malik@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: shveta malik @ 2025-08-07 08:13 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Thu, Aug 7, 2025 at 12:25 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> On Tue, Aug 5, 2025 at 5:54 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > Currently we log conflicts to the server's log file and updates, this
> > approach has limitations, 1) Difficult to query and analyze, parsing
> > plain text log files for conflict details is inefficient. 2) Lack of
> > structured data, key conflict attributes (table, operation, old/new
> > data, LSN, etc.) are not readily available in a structured, queryable
> > format. 3) Difficult for external monitoring tools or custom
> > resolution scripts to consume conflict data directly.
> >
> > This proposal aims to address these limitations by introducing a
> > conflict log history table, providing a structured, and queryable
> > record of all logical replication conflicts. This should be a
> > configurable option whether to log into the conflict log history
> > table, server logs or both.
> >
>
> +1 for the idea.
>
> > This proposal has two main design questions:
> > ===================================
> >
> > 1. How do we store conflicting tuples from different tables?
> > Using a JSON column to store the row data seems like the most flexible
> > solution, as it can accommodate different table schemas.
>
> Yes, that is one option. I have not looked into details myself, but
> you can also explore 'anyarray' used in pg_statistics to store 'Column
> data values of the appropriate kind'.
>
> > 2. Should this be a system table or a user table?
> > a) System Table: Storing this in a system catalog is simple, but
> > catalogs aren't designed for ever-growing data. While pg_large_object
> > is an exception, this is not what we generally do IMHO.
> > b) User Table: This offers more flexibility. We could allow a user to
> > specify the table name during CREATE SUBSCRIPTION. Then we choose to
> > either create the table internally or let the user create the table
> > with a predefined schema.
> >
> > A potential drawback is that a user might drop or alter the table.
> > However, we could mitigate this risk by simply logging a WARNING if
> > the table is configured but an insertion fails.
>
> I believe it makes more sense for this to be a catalog table rather
> than a user table. I wanted to check if we already have a large
> catalog table of this kind, and I think pg_statistic could be an
> example of a sizable catalog table. To get a rough idea of how size
> scales with data, I ran a quick experiment: I created 1000 tables,
> each with 2 JSON columns, 1 text column, and 2 integer columns. Then,
> I inserted 1000 rows into each table and ran ANALYZE to collect
> statistics. Here’s what I observed on a fresh database before and
> after:
>
> Before:
> pg_statistic row count: 412
> Table size: ~256 kB
>
> After:
> pg_statistic row count: 6,412
> Table size: ~5.3 MB
>
> Although it isn’t an exact comparison, this gives us some insight into
> how the statistics catalog table size grows with the number of rows.
> It doesn’t seem excessively large with 6k rows, given the fact that
> pg_statistic itself is a complex table having many 'anyarray'-type
> columns.
>
> That said, irrespective of what we decide, it would be ideal to offer
> users an option for automatic purging, perhaps via a retention period
> parameter like conflict_stats_retention_period (say default to 30
> days), or a manual purge API such as purge_conflict_stats('older than
> date'). I wasn’t able to find any such purge mechanism for PostgreSQL
> stats tables, but Oracle does provide such purging options for some of
> their statistics tables (not related to conflicts), see [1], [2].
> And to manage it better, it could be range partitioned on timestamp.
>
It seems BDR also has one such conflict-log table which is a catalog
table and is also partitioned on time. It has a default retention
period of 30 days. See 'bdr.conflict_history' mentioned under
'catalogs' in [1]
[1]: https://www.enterprisedb.com/docs/pgd/latest/reference/tables-views-functions/#user-visible-catalogs...
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-08-07 09:38 Dilip Kumar <dilipbalaut@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-08-07 09:38 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, Aug 7, 2025 at 1:43 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> On Thu, Aug 7, 2025 at 12:25 PM shveta malik <shveta.malik@gmail.com> wrote:
Thanks Shveta for your opinion on the design.
> > On Tue, Aug 5, 2025 at 5:54 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > This proposal aims to address these limitations by introducing a
> > > conflict log history table, providing a structured, and queryable
> > > record of all logical replication conflicts. This should be a
> > > configurable option whether to log into the conflict log history
> > > table, server logs or both.
> > >
> >
> > +1 for the idea.
Thanks
> >
> > > This proposal has two main design questions:
> > > ===================================
> > >
> > > 1. How do we store conflicting tuples from different tables?
> > > Using a JSON column to store the row data seems like the most flexible
> > > solution, as it can accommodate different table schemas.
> >
> > Yes, that is one option. I have not looked into details myself, but
> > you can also explore 'anyarray' used in pg_statistics to store 'Column
> > data values of the appropriate kind'.
I think conversion from row to json and json to row is convenient and
also other extensions like pgactive/bdr also provide as JSON. But we
can explore this alternative options as well, thanks
> > > 2. Should this be a system table or a user table?
> > > a) System Table: Storing this in a system catalog is simple, but
> > > catalogs aren't designed for ever-growing data. While pg_large_object
> > > is an exception, this is not what we generally do IMHO.
> > > b) User Table: This offers more flexibility. We could allow a user to
> > > specify the table name during CREATE SUBSCRIPTION. Then we choose to
> > > either create the table internally or let the user create the table
> > > with a predefined schema.
> > >
> > > A potential drawback is that a user might drop or alter the table.
> > > However, we could mitigate this risk by simply logging a WARNING if
> > > the table is configured but an insertion fails.
> >
> > I believe it makes more sense for this to be a catalog table rather
> > than a user table. I wanted to check if we already have a large
> > catalog table of this kind, and I think pg_statistic could be an
> > example of a sizable catalog table. To get a rough idea of how size
> > scales with data, I ran a quick experiment: I created 1000 tables,
> > each with 2 JSON columns, 1 text column, and 2 integer columns. Then,
> > I inserted 1000 rows into each table and ran ANALYZE to collect
> > statistics. Here’s what I observed on a fresh database before and
> > after:
> >
> > Before:
> > pg_statistic row count: 412
> > Table size: ~256 kB
> >
> > After:
> > pg_statistic row count: 6,412
> > Table size: ~5.3 MB
> >
> > Although it isn’t an exact comparison, this gives us some insight into
> > how the statistics catalog table size grows with the number of rows.
> > It doesn’t seem excessively large with 6k rows, given the fact that
> > pg_statistic itself is a complex table having many 'anyarray'-type
> > columns.
Yeah that's good analysis, apart from this pg_largeobject is also a
catalog which grows with each large object and growth rate for that
will be very high because it stores large object data in catalog.
> >
> > That said, irrespective of what we decide, it would be ideal to offer
> > users an option for automatic purging, perhaps via a retention period
> > parameter like conflict_stats_retention_period (say default to 30
> > days), or a manual purge API such as purge_conflict_stats('older than
> > date'). I wasn’t able to find any such purge mechanism for PostgreSQL
> > stats tables, but Oracle does provide such purging options for some of
> > their statistics tables (not related to conflicts), see [1], [2].
> > And to manage it better, it could be range partitioned on timestamp.
Yeah that's an interesting suggestion to timestamp based partitioning
it for purging.
> It seems BDR also has one such conflict-log table which is a catalog
> table and is also partitioned on time. It has a default retention
> period of 30 days. See 'bdr.conflict_history' mentioned under
> 'catalogs' in [1]
>
> [1]: https://www.enterprisedb.com/docs/pgd/latest/reference/tables-views-functions/#user-visible-catalogs...
Actually bdr is an extension and this table is under extension
namespace (bdr.conflict_history) so this is not really a catalog but
its a extension managed table. So logically for PostgreSQL its an
user table but yeah this is created and managed by the extension.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-08-08 03:28 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: shveta malik @ 2025-08-08 03:28 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Thu, Aug 7, 2025 at 3:08 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Thu, Aug 7, 2025 at 1:43 PM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > On Thu, Aug 7, 2025 at 12:25 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> Thanks Shveta for your opinion on the design.
>
> > > On Tue, Aug 5, 2025 at 5:54 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > >
>
> > > > This proposal aims to address these limitations by introducing a
> > > > conflict log history table, providing a structured, and queryable
> > > > record of all logical replication conflicts. This should be a
> > > > configurable option whether to log into the conflict log history
> > > > table, server logs or both.
> > > >
> > >
> > > +1 for the idea.
>
> Thanks
>
> > >
> > > > This proposal has two main design questions:
> > > > ===================================
> > > >
> > > > 1. How do we store conflicting tuples from different tables?
> > > > Using a JSON column to store the row data seems like the most flexible
> > > > solution, as it can accommodate different table schemas.
> > >
> > > Yes, that is one option. I have not looked into details myself, but
> > > you can also explore 'anyarray' used in pg_statistics to store 'Column
> > > data values of the appropriate kind'.
>
> I think conversion from row to json and json to row is convenient and
> also other extensions like pgactive/bdr also provide as JSON.
Okay. Agreed.
> But we
> can explore this alternative options as well, thanks
>
> > > > 2. Should this be a system table or a user table?
> > > > a) System Table: Storing this in a system catalog is simple, but
> > > > catalogs aren't designed for ever-growing data. While pg_large_object
> > > > is an exception, this is not what we generally do IMHO.
> > > > b) User Table: This offers more flexibility. We could allow a user to
> > > > specify the table name during CREATE SUBSCRIPTION. Then we choose to
> > > > either create the table internally or let the user create the table
> > > > with a predefined schema.
> > > >
> > > > A potential drawback is that a user might drop or alter the table.
> > > > However, we could mitigate this risk by simply logging a WARNING if
> > > > the table is configured but an insertion fails.
> > >
> > > I believe it makes more sense for this to be a catalog table rather
> > > than a user table. I wanted to check if we already have a large
> > > catalog table of this kind, and I think pg_statistic could be an
> > > example of a sizable catalog table. To get a rough idea of how size
> > > scales with data, I ran a quick experiment: I created 1000 tables,
> > > each with 2 JSON columns, 1 text column, and 2 integer columns. Then,
> > > I inserted 1000 rows into each table and ran ANALYZE to collect
> > > statistics. Here’s what I observed on a fresh database before and
> > > after:
> > >
> > > Before:
> > > pg_statistic row count: 412
> > > Table size: ~256 kB
> > >
> > > After:
> > > pg_statistic row count: 6,412
> > > Table size: ~5.3 MB
> > >
> > > Although it isn’t an exact comparison, this gives us some insight into
> > > how the statistics catalog table size grows with the number of rows.
> > > It doesn’t seem excessively large with 6k rows, given the fact that
> > > pg_statistic itself is a complex table having many 'anyarray'-type
> > > columns.
>
> Yeah that's good analysis, apart from this pg_largeobject is also a
> catalog which grows with each large object and growth rate for that
> will be very high because it stores large object data in catalog.
>
> > >
> > > That said, irrespective of what we decide, it would be ideal to offer
> > > users an option for automatic purging, perhaps via a retention period
> > > parameter like conflict_stats_retention_period (say default to 30
> > > days), or a manual purge API such as purge_conflict_stats('older than
> > > date'). I wasn’t able to find any such purge mechanism for PostgreSQL
> > > stats tables, but Oracle does provide such purging options for some of
> > > their statistics tables (not related to conflicts), see [1], [2].
> > > And to manage it better, it could be range partitioned on timestamp.
>
> Yeah that's an interesting suggestion to timestamp based partitioning
> it for purging.
>
> > It seems BDR also has one such conflict-log table which is a catalog
> > table and is also partitioned on time. It has a default retention
> > period of 30 days. See 'bdr.conflict_history' mentioned under
> > 'catalogs' in [1]
> >
> > [1]: https://www.enterprisedb.com/docs/pgd/latest/reference/tables-views-functions/#user-visible-catalogs...
>
> Actually bdr is an extension and this table is under extension
> namespace (bdr.conflict_history) so this is not really a catalog but
> its a extension managed table.
Yes, right. Sorry for confusion.
> So logically for PostgreSQL its an
> user table but yeah this is created and managed by the extension.
>
Any idea if the user can alter/drop or perform any DML on it? I could
not find any details on this part.
> --
> Regards,
> Dilip Kumar
> Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-08-08 04:31 Dilip Kumar <dilipbalaut@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 2 replies; 912+ messages in thread
From: Dilip Kumar @ 2025-08-08 04:31 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Fri, Aug 8, 2025 at 8:58 AM shveta malik <shveta.malik@gmail.com> wrote:
>
> On Thu, Aug 7, 2025 at 3:08 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > So logically for PostgreSQL its an
> > user table but yeah this is created and managed by the extension.
> >
>
> Any idea if the user can alter/drop or perform any DML on it? I could
> not find any details on this part.
In my experience, for such extension managed tables where we want them
to behave like catalog, generally users are just granted with SELECT
permission. So although it is not a catalog but for accessibility
wise for non admin users it is like a catalog. IMHO, even if we
choose to create a user table for conflict log history we can also
control the permissions similarly. What's your opinion on this?
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-08-08 08:42 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
1 sibling, 0 replies; 912+ messages in thread
From: shveta malik @ 2025-08-08 08:42 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Fri, Aug 8, 2025 at 10:01 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Fri, Aug 8, 2025 at 8:58 AM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > On Thu, Aug 7, 2025 at 3:08 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > So logically for PostgreSQL its an
> > > user table but yeah this is created and managed by the extension.
> > >
> >
> > Any idea if the user can alter/drop or perform any DML on it? I could
> > not find any details on this part.
>
> In my experience, for such extension managed tables where we want them
> to behave like catalog, generally users are just granted with SELECT
> permission. So although it is not a catalog but for accessibility
> wise for non admin users it is like a catalog. IMHO, even if we
> choose to create a user table for conflict log history we can also
> control the permissions similarly.
>
Yes, it can be done. Technically there is nothing preventing us from
doing it. But in my experience, I have never seen any
system-maintained statistics tables to be a user table rather than
catalog table. Extensions are a different case; they typically manage
their own tables, which are not part of the system catalog. But if any
such stats related functionality is part of the core database, it
generally makes more sense to implement it as a catalog table
(provided there are no major obstacles to doing so). But I am curious
to know what others think here.
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-08-13 10:08 Amit Kapila <amit.kapila16@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
1 sibling, 2 replies; 912+ messages in thread
From: Amit Kapila @ 2025-08-13 10:08 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Fri, Aug 8, 2025 at 10:01 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Fri, Aug 8, 2025 at 8:58 AM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > On Thu, Aug 7, 2025 at 3:08 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > So logically for PostgreSQL its an
> > > user table but yeah this is created and managed by the extension.
> > >
> >
> > Any idea if the user can alter/drop or perform any DML on it? I could
> > not find any details on this part.
>
> In my experience, for such extension managed tables where we want them
> to behave like catalog, generally users are just granted with SELECT
> permission. So although it is not a catalog but for accessibility
> wise for non admin users it is like a catalog. IMHO, even if we
> choose to create a user table for conflict log history we can also
> control the permissions similarly. What's your opinion on this?
>
Yes, I think it is important to control permissions on this table even
if it is a user table. How about giving SELECT, DELETE, TRUNCATE
permissions to subscription owner assuming we create one such table
per subscription?
It should be a user table due to following reasons (a) It is an ever
growing table by definition and we need some level of user control to
manage it (like remove the old data); (b) We may want some sort of
partitioning streategy to manage it, even though, we decide to do it
ourselves now but in future, we should allow user to also specify it;
(c) We may also want user to specify what exact information she wants
to get stored considering in future we want resolutions to also be
stored in it. See a somewhat similar proposal to store errors during
copy by Tom [1]; (d) In a near-by thread, we are discussing storing
errors during copy in user table [2] and we have some similarity with
that proposal as well.
If we agree on this then the next thing to consider is whether we
allow users to create such a table or do it ourselves. In the long
term, we may want both but for simplicity, we can auto-create
ourselves during CREATE SUBSCRIPTION with some option. BTW, if we
decide to let user create it then we can consider the idea of TYPED
tables as discussed in emails [3][4].
For user tables, we need to consider how to avoid replicating these
tables for publications that use FOR ALL TABLES specifier. One idea is
to use EXCLUDE table functionality as being discussed in thread [5]
but that would also be a bit tricky especially if we decide to create
such a table automatically. One naive idea is that internally we skip
sending changes from this table for "FOR ALL TABLES" publication, and
we shouldn't allow creating publication for this table. OTOH, if we
allow the user to create and specify this table, we can ask her to
specify with EXCLUDE syntax in publication. This needs more thoughts.
[1] - https://www.postgresql.org/message-id/flat/752672.1699474336%40sss.pgh.pa.us#b8450be5645c4252d7d02cf...
[2] - https://www.postgresql.org/message-id/CACJufxH_OJpVra%3D0c4ow8fbxHj7heMcVaTNEPa5vAurSeNA-6Q%40mail.g...
[3] - https://www.postgresql.org/message-id/28c420cf-f25d-44f1-89fd-04ef0b2dd3db%40dunslane.net
[4] - https://www.postgresql.org/message-id/CADrsxdYG%2B%2BK%3DiKjRm35u03q-Nb0tQPJaqjxnA2mGt5O%3DDht7sw%40...
[5] - https://www.postgresql.org/message-id/CANhcyEW%2BuJB_bvQLEaZCgoRTc1%3Di%2BQnrPPHxZ2%3D0SBSCyj9pkg%40...
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-08-14 10:56 Alastair Turner <minion@decodable.me>
parent: Amit Kapila <amit.kapila16@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: Alastair Turner @ 2025-08-14 10:56 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: Dilip Kumar <dilipbalaut@gmail.com>; shveta malik <shveta.malik@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Wed, 13 Aug 2025 at 11:09, Amit Kapila <amit.kapila16@gmail.com> wrote:
> On Fri, Aug 8, 2025 at 10:01 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Fri, Aug 8, 2025 at 8:58 AM shveta malik <shveta.malik@gmail.com>
> wrote:
> > >
> > > On Thu, Aug 7, 2025 at 3:08 PM Dilip Kumar <dilipbalaut@gmail.com>
> wrote:
> > > >
> > > > So logically for PostgreSQL its an
> > > > user table but yeah this is created and managed by the extension.
> > > >
> > >
> > > Any idea if the user can alter/drop or perform any DML on it? I could
> > > not find any details on this part.
> >
> > In my experience, for such extension managed tables where we want them
> > to behave like catalog, generally users are just granted with SELECT
> > permission. So although it is not a catalog but for accessibility
> > wise for non admin users it is like a catalog. IMHO, even if we
> > choose to create a user table for conflict log history we can also
> > control the permissions similarly. What's your opinion on this?
> >
>
> Yes, I think it is important to control permissions on this table even
> if it is a user table. How about giving SELECT, DELETE, TRUNCATE
> permissions to subscription owner assuming we create one such table
> per subscription?
>
> It should be a user table due to following reasons (a) It is an ever
> growing table by definition and we need some level of user control to
> manage it (like remove the old data); (b) We may want some sort of
> partitioning streategy to manage it, even though, we decide to do it
> ourselves now but in future, we should allow user to also specify it;
> (c) We may also want user to specify what exact information she wants
> to get stored considering in future we want resolutions to also be
> stored in it. See a somewhat similar proposal to store errors during
> copy by Tom [1]; (d) In a near-by thread, we are discussing storing
> errors during copy in user table [2] and we have some similarity with
> that proposal as well.
>
> If we agree on this then the next thing to consider is whether we
> allow users to create such a table or do it ourselves. In the long
> term, we may want both but for simplicity, we can auto-create
> ourselves during CREATE SUBSCRIPTION with some option. BTW, if we
> decide to let user create it then we can consider the idea of TYPED
> tables as discussed in emails [3][4].
>
Having it be a user table, and specifying the table per subscription sounds
good. This is very similar to how the load error tables for CloudBerry
behave, for instance. To have both options for table creation, CREATE ...
IF NOT EXISTS semantics work well - if the option on CREATE SUBSCRIPTION
specifies an existing table of the right type use it, or create one with
the name supplied. This would also give the user control over whether to
have one table per subscription, one central table or anything in between.
Rather than constraining permissions on the table, the CREATE SUBSCRIPTION
command could create a dependency relationship between the table and the
subscription.This would prevent removal of the table, even by a superuser.
> For user tables, we need to consider how to avoid replicating these
> tables for publications that use FOR ALL TABLES specifier. One idea is
> to use EXCLUDE table functionality as being discussed in thread [5]
> but that would also be a bit tricky especially if we decide to create
> such a table automatically. One naive idea is that internally we skip
> sending changes from this table for "FOR ALL TABLES" publication, and
> we shouldn't allow creating publication for this table. OTOH, if we
> allow the user to create and specify this table, we can ask her to
> specify with EXCLUDE syntax in publication. This needs more thoughts.
>
If a dependency relationship is established between the error table and the
subscription, could this be used as a basis for filtering the error tables
from FOR ALL TABLES subscriptions?
Regards
Alastair
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-08-15 06:52 Amit Kapila <amit.kapila16@gmail.com>
parent: Alastair Turner <minion@decodable.me>
0 siblings, 0 replies; 912+ messages in thread
From: Amit Kapila @ 2025-08-15 06:52 UTC (permalink / raw)
To: Alastair Turner <minion@decodable.me>; +Cc: Dilip Kumar <dilipbalaut@gmail.com>; shveta malik <shveta.malik@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, Aug 14, 2025 at 4:26 PM Alastair Turner <minion@decodable.me> wrote:
>
> On Wed, 13 Aug 2025 at 11:09, Amit Kapila <amit.kapila16@gmail.com> wrote:
>>
>> On Fri, Aug 8, 2025 at 10:01 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>> >
>> > On Fri, Aug 8, 2025 at 8:58 AM shveta malik <shveta.malik@gmail.com> wrote:
>> > >
>> > > On Thu, Aug 7, 2025 at 3:08 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>> > > >
>> > > > So logically for PostgreSQL its an
>> > > > user table but yeah this is created and managed by the extension.
>> > > >
>> > >
>> > > Any idea if the user can alter/drop or perform any DML on it? I could
>> > > not find any details on this part.
>> >
>> > In my experience, for such extension managed tables where we want them
>> > to behave like catalog, generally users are just granted with SELECT
>> > permission. So although it is not a catalog but for accessibility
>> > wise for non admin users it is like a catalog. IMHO, even if we
>> > choose to create a user table for conflict log history we can also
>> > control the permissions similarly. What's your opinion on this?
>> >
>>
>> Yes, I think it is important to control permissions on this table even
>> if it is a user table. How about giving SELECT, DELETE, TRUNCATE
>> permissions to subscription owner assuming we create one such table
>> per subscription?
>>
>> It should be a user table due to following reasons (a) It is an ever
>> growing table by definition and we need some level of user control to
>> manage it (like remove the old data); (b) We may want some sort of
>> partitioning streategy to manage it, even though, we decide to do it
>> ourselves now but in future, we should allow user to also specify it;
>> (c) We may also want user to specify what exact information she wants
>> to get stored considering in future we want resolutions to also be
>> stored in it. See a somewhat similar proposal to store errors during
>> copy by Tom [1]; (d) In a near-by thread, we are discussing storing
>> errors during copy in user table [2] and we have some similarity with
>> that proposal as well.
>>
>> If we agree on this then the next thing to consider is whether we
>> allow users to create such a table or do it ourselves. In the long
>> term, we may want both but for simplicity, we can auto-create
>> ourselves during CREATE SUBSCRIPTION with some option. BTW, if we
>> decide to let user create it then we can consider the idea of TYPED
>> tables as discussed in emails [3][4].
>
>
> Having it be a user table, and specifying the table per subscription sounds good. This is very similar to how the load error tables for CloudBerry behave, for instance. To have both options for table creation, CREATE ... IF NOT EXISTS semantics work well - if the option on CREATE SUBSCRIPTION specifies an existing table of the right type use it, or create one with the name supplied. This would also give the user control over whether to have one table per subscription, one central table or anything in between.
>
Sounds reasonable. I think the first version we can let such a table
be created automatically with some option(s) with subscription. Then,
in subsequent versions, we can extend the functionality to allow
existing tables.
>
> Rather than constraining permissions on the table, the CREATE SUBSCRIPTION command could create a dependency relationship between the table and the subscription.This would prevent removal of the table, even by a superuser.
>
Okay, that makes sense. But, we still probably want to disallow users
from inserting or updating rows in the conflict table.
>>
>> For user tables, we need to consider how to avoid replicating these
>> tables for publications that use FOR ALL TABLES specifier. One idea is
>> to use EXCLUDE table functionality as being discussed in thread [5]
>> but that would also be a bit tricky especially if we decide to create
>> such a table automatically. One naive idea is that internally we skip
>> sending changes from this table for "FOR ALL TABLES" publication, and
>> we shouldn't allow creating publication for this table. OTOH, if we
>> allow the user to create and specify this table, we can ask her to
>> specify with EXCLUDE syntax in publication. This needs more thoughts.
>
>
> If a dependency relationship is established between the error table and the subscription, could this be used as a basis for filtering the error tables from FOR ALL TABLES subscriptions?
>
Yeah, that is worth considering.
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-08-15 09:00 Dilip Kumar <dilipbalaut@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-08-15 09:00 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Wed, Aug 13, 2025 at 3:39 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Fri, Aug 8, 2025 at 10:01 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Fri, Aug 8, 2025 at 8:58 AM shveta malik <shveta.malik@gmail.com> wrote:
> > >
> > > On Thu, Aug 7, 2025 at 3:08 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > >
> > > > So logically for PostgreSQL its an
> > > > user table but yeah this is created and managed by the extension.
> > > >
> > >
> > > Any idea if the user can alter/drop or perform any DML on it? I could
> > > not find any details on this part.
> >
> > In my experience, for such extension managed tables where we want them
> > to behave like catalog, generally users are just granted with SELECT
> > permission. So although it is not a catalog but for accessibility
> > wise for non admin users it is like a catalog. IMHO, even if we
> > choose to create a user table for conflict log history we can also
> > control the permissions similarly. What's your opinion on this?
> >
>
> Yes, I think it is important to control permissions on this table even
> if it is a user table. How about giving SELECT, DELETE, TRUNCATE
> permissions to subscription owner assuming we create one such table
> per subscription?
Right, we need to control the permission. I am not sure whether we
want a per subscription table or a common one. Earlier I was thinking
of a single table, but I think per subscription is not a bad idea
especially for managing the permissions. And there can not be a
really huge number of subscriptions that we need to worry about
creating many conflict log history tables and that too we will only
create such tables when users pass that subscription option.
> It should be a user table due to following reasons (a) It is an ever
> growing table by definition and we need some level of user control to
> manage it (like remove the old data); (b) We may want some sort of
> partitioning streategy to manage it, even though, we decide to do it
> ourselves now but in future, we should allow user to also specify it;
Maybe we can partition by range on date (when entry is inserted) .
That way it would be easy to get rid of older partitions for users.
> (c) We may also want user to specify what exact information she wants
> to get stored considering in future we want resolutions to also be
> stored in it. See a somewhat similar proposal to store errors during
> copy by Tom [1]; (d) In a near-by thread, we are discussing storing
> errors during copy in user table [2] and we have some similarity with
> that proposal as well.
Right, we may consider that as well.
> If we agree on this then the next thing to consider is whether we
> allow users to create such a table or do it ourselves. In the long
> term, we may want both but for simplicity, we can auto-create
> ourselves during CREATE SUBSCRIPTION with some option. BTW, if we
> decide to let user create it then we can consider the idea of TYPED
> tables as discussed in emails [3][4].
Yeah that's an interesting option.
>
> For user tables, we need to consider how to avoid replicating these
> tables for publications that use FOR ALL TABLES specifier. One idea is
> to use EXCLUDE table functionality as being discussed in thread [5]
> but that would also be a bit tricky especially if we decide to create
> such a table automatically. One naive idea is that internally we skip
> sending changes from this table for "FOR ALL TABLES" publication, and
> we shouldn't allow creating publication for this table. OTOH, if we
> allow the user to create and specify this table, we can ask her to
> specify with EXCLUDE syntax in publication. This needs more thoughts.
Yes this needs more thought, I will think more on this point and respond.
Yet another question is about table names, whether we keep some
standard name like conflict_log_history_$subid or let users pass the
name.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-08-18 06:55 Amit Kapila <amit.kapila16@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Amit Kapila @ 2025-08-18 06:55 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Fri, Aug 15, 2025 at 2:31 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> Yet another question is about table names, whether we keep some
> standard name like conflict_log_history_$subid or let users pass the
> name.
>
It would be good if we can let the user specify the table_name and if
she didn't specify then use an internally generated name. I think it
will be somewhat similar to slot_name. However, in this case, there is
one challenge which is how can we decide whether the schema of the
user provided table_name is correct or not? Do we compare it with the
standard schema we are planning to use?
One idea to keep things simple for the first version is that we allow
users to specify the table_name for storing conflicts but the table
should be created internally and if the same name table already
exists, we can give an ERROR. Then we can later extend the
functionality to even allow storing conflicts in pre-created tables
with more checks about its schema.
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-08-20 06:16 Dilip Kumar <dilipbalaut@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-08-20 06:16 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Mon, Aug 18, 2025 at 12:25 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Fri, Aug 15, 2025 at 2:31 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > Yet another question is about table names, whether we keep some
> > standard name like conflict_log_history_$subid or let users pass the
> > name.
> >
>
> It would be good if we can let the user specify the table_name and if
> she didn't specify then use an internally generated name. I think it
> will be somewhat similar to slot_name. However, in this case, there is
> one challenge which is how can we decide whether the schema of the
> user provided table_name is correct or not? Do we compare it with the
> standard schema we are planning to use?
Ideally we can do that, if you see in this thread [1] there is a patch
[2] which first try to validate the table schema and if it doesn't
exist it creates it on its own. And it seems fine to me.
> One idea to keep things simple for the first version is that we allow
> users to specify the table_name for storing conflicts but the table
> should be created internally and if the same name table already
> exists, we can give an ERROR. Then we can later extend the
> functionality to even allow storing conflicts in pre-created tables
> with more checks about its schema.
That's fair too. I am wondering what namespace we should create this
user table in. If we are creating internally, I assume the user should
provide a schema qualified name right?
[1] https://www.postgresql.org/message-id/flat/752672.1699474336%40sss.pgh.pa.us#b8450be5645c4252d7d02cf...
[2] https://www.postgresql.org/message-id/attachment/152792/v8-0001-Add-a-new-COPY-option-SAVE_ERROR.pat...
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-08-20 12:16 Amit Kapila <amit.kapila16@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Amit Kapila @ 2025-08-20 12:16 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Wed, Aug 20, 2025 at 11:47 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Mon, Aug 18, 2025 at 12:25 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> >
>
> > One idea to keep things simple for the first version is that we allow
> > users to specify the table_name for storing conflicts but the table
> > should be created internally and if the same name table already
> > exists, we can give an ERROR. Then we can later extend the
> > functionality to even allow storing conflicts in pre-created tables
> > with more checks about its schema.
>
> That's fair too. I am wondering what namespace we should create this
> user table in. If we are creating internally, I assume the user should
> provide a schema qualified name right?
>
Yeah, but if not provided then we should create it based on
search_path similar to what we do when user created the table from
psql.
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-08-21 03:47 Dilip Kumar <dilipbalaut@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-08-21 03:47 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Wed, Aug 20, 2025 at 5:46 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Wed, Aug 20, 2025 at 11:47 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Mon, Aug 18, 2025 at 12:25 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > >
> >
> > > One idea to keep things simple for the first version is that we allow
> > > users to specify the table_name for storing conflicts but the table
> > > should be created internally and if the same name table already
> > > exists, we can give an ERROR. Then we can later extend the
> > > functionality to even allow storing conflicts in pre-created tables
> > > with more checks about its schema.
> >
> > That's fair too. I am wondering what namespace we should create this
> > user table in. If we are creating internally, I assume the user should
> > provide a schema qualified name right?
> >
>
> Yeah, but if not provided then we should create it based on
> search_path similar to what we do when user created the table from
> psql.
Yeah that makes sense.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-09-06 09:38 Dilip Kumar <dilipbalaut@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-09-06 09:38 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, Aug 21, 2025 at 9:17 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Wed, Aug 20, 2025 at 5:46 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> >
> > On Wed, Aug 20, 2025 at 11:47 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > On Mon, Aug 18, 2025 at 12:25 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > > >
> > >
> > > > One idea to keep things simple for the first version is that we allow
> > > > users to specify the table_name for storing conflicts but the table
> > > > should be created internally and if the same name table already
> > > > exists, we can give an ERROR. Then we can later extend the
> > > > functionality to even allow storing conflicts in pre-created tables
> > > > with more checks about its schema.
> > >
> > > That's fair too. I am wondering what namespace we should create this
> > > user table in. If we are creating internally, I assume the user should
> > > provide a schema qualified name right?
> > >
> >
> > Yeah, but if not provided then we should create it based on
> > search_path similar to what we do when user created the table from
> > psql.
While working on the patch, I see there are some open questions
1. We decided to pass the conflict history table name during
subscription creation. And it makes sense to create this table when
the CREATE SUBSCRIPTION command is executed. A potential concern is
that the subscription owner will also own this table, having full
control over it, including the ability to drop or alter its schema.
This might not be an issue. If an INSERT into the conflict table
fails, we can check the table's existence and schema. If they are not
as expected, the conflict log history option can be disabled and
re-enabled later via ALTER SUBSCRIPTION.
2. A further challenge is how to exclude these tables from publishing
changes. If we support a subscription-level log history table and the
user publishes ALL TABLES, the output plugin uses
is_publishable_relation() to check if a table is publishable. However,
applying the same logic here would require checking each subscription
on the node to see if the table is designated as a conflict log
history table for any subscription, which could be costly.
3. And one last thing is about should we consider dropping this table
when we drop the subscription, I think this makes sense as we are
internally creating it while creating the subscription.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-09-07 08:12 Alastair Turner <minion@decodable.me>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Alastair Turner @ 2025-09-07 08:12 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Hi Dilip
Thanks for working on this, I think it will make conflict detection a lot
more useful.
On Sat, 6 Sept 2025, 10:38 Dilip Kumar, <dilipbalaut@gmail.com> wrote:
> While working on the patch, I see there are some open questions
>
> 1. We decided to pass the conflict history table name during
> subscription creation. And it makes sense to create this table when
> the CREATE SUBSCRIPTION command is executed. A potential concern is
> that the subscription owner will also own this table, having full
> control over it, including the ability to drop or alter its schema.
...
>
Typed tables and the dependency framework can address this concern. The
schema of a typed table cannot be changed. If the subscription is marked as
a dependency of the log table, the table cannot be dropped while the
subscription exists.
> 2. A further challenge is how to exclude these tables from publishing
> changes. If we support a subscription-level log history table and the
> user publishes ALL TABLES, the output plugin uses
> is_publishable_relation() to check if a table is publishable. However,
> applying the same logic here would require checking each subscription
> on the node to see if the table is designated as a conflict log
> history table for any subscription, which could be costly.
>
Checking the type of a table and/or whether a subscription object depends
on it in a certain way would be a far less costly operation to add to
is_publishable_relation()
> 3. And one last thing is about should we consider dropping this table
> when we drop the subscription, I think this makes sense as we are
> internally creating it while creating the subscription.
>
Having to clean up the log table explicitly is likely to annoy users far
less than having the conflict data destroyed as a side effect of another
operation. I would strongly suggest leaving the table in place when the
subscription is dropped.
Regards
Alastair
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-09-08 06:31 Dilip Kumar <dilipbalaut@gmail.com>
parent: Alastair Turner <minion@decodable.me>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-09-08 06:31 UTC (permalink / raw)
To: Alastair Turner <minion@decodable.me>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Sun, Sep 7, 2025 at 1:42 PM Alastair Turner <minion@decodable.me> wrote:
>
> Hi Dilip
>
> Thanks for working on this, I think it will make conflict detection a lot more useful.
Thanks for the suggestions, please find my reply inline.
> On Sat, 6 Sept 2025, 10:38 Dilip Kumar, <dilipbalaut@gmail.com> wrote:
>>
>> While working on the patch, I see there are some open questions
>>
>> 1. We decided to pass the conflict history table name during
>> subscription creation. And it makes sense to create this table when
>> the CREATE SUBSCRIPTION command is executed. A potential concern is
>> that the subscription owner will also own this table, having full
>> control over it, including the ability to drop or alter its schema.
>
> Typed tables and the dependency framework can address this concern. The schema of a typed table cannot be changed. If the subscription is marked as a dependency of the log table, the table cannot be dropped while the subscription exists.
Yeah type table can be useful here, but only concern is when do we
create this type. One option is whenever we can create a catalog
relation say "conflict_log_history" that will create a type and then
for each subscription if we need to create the conflict history table
we can create it as "conflict_log_history" type, but this might not be
a best option as we are creating catalog just for using this type.
Second option is to create a type while creating a table itself but
then again the problem remains the same as subscription owners get
control over altering the schema of the type itself. So the goal is
we want this type to be created such that it can not be altered so
IMHO option1 is more suitable i.e. creating conflict_log_history as
catalog and per subscription table can be created as this type.
>>
>> 2. A further challenge is how to exclude these tables from publishing
>> changes. If we support a subscription-level log history table and the
>> user publishes ALL TABLES, the output plugin uses
>> is_publishable_relation() to check if a table is publishable. However,
>> applying the same logic here would require checking each subscription
>> on the node to see if the table is designated as a conflict log
>> history table for any subscription, which could be costly.
>
>
> Checking the type of a table and/or whether a subscription object depends on it in a certain way would be a far less costly operation to add to is_publishable_relation()
+1
>
>>
>> 3. And one last thing is about should we consider dropping this table
>> when we drop the subscription, I think this makes sense as we are
>> internally creating it while creating the subscription.
>
>
> Having to clean up the log table explicitly is likely to annoy users far less than having the conflict data destroyed as a side effect of another operation. I would strongly suggest leaving the table in place when the subscription is dropped.
Thanks for the input, I would like to hear opinions from others as
well here. I agree that implicitly getting rid of the conflict
history might be problematic but we also need to consider that we are
considering dropping this when the whole subscription is dropped. Not
sure even after subscription drop users will be interested in conflict
history, if yes then they need to be aware of preserving that isn't
it.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-09-10 09:55 Amit Kapila <amit.kapila16@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Amit Kapila @ 2025-09-10 09:55 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Alastair Turner <minion@decodable.me>; shveta malik <shveta.malik@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Mon, Sep 8, 2025 at 12:01 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Sun, Sep 7, 2025 at 1:42 PM Alastair Turner <minion@decodable.me> wrote:
> >
> > Hi Dilip
> >
> > Thanks for working on this, I think it will make conflict detection a lot more useful.
>
> Thanks for the suggestions, please find my reply inline.
>
> > On Sat, 6 Sept 2025, 10:38 Dilip Kumar, <dilipbalaut@gmail.com> wrote:
> >>
> >> While working on the patch, I see there are some open questions
> >>
> >> 1. We decided to pass the conflict history table name during
> >> subscription creation. And it makes sense to create this table when
> >> the CREATE SUBSCRIPTION command is executed. A potential concern is
> >> that the subscription owner will also own this table, having full
> >> control over it, including the ability to drop or alter its schema.
>
> >
> > Typed tables and the dependency framework can address this concern. The schema of a typed table cannot be changed. If the subscription is marked as a dependency of the log table, the table cannot be dropped while the subscription exists.
>
> Yeah type table can be useful here, but only concern is when do we
> create this type.
>
How about having this as a built-in type?
> One option is whenever we can create a catalog
> relation say "conflict_log_history" that will create a type and then
> for each subscription if we need to create the conflict history table
> we can create it as "conflict_log_history" type, but this might not be
> a best option as we are creating catalog just for using this type.
> Second option is to create a type while creating a table itself but
> then again the problem remains the same as subscription owners get
> control over altering the schema of the type itself. So the goal is
> we want this type to be created such that it can not be altered so
> IMHO option1 is more suitable i.e. creating conflict_log_history as
> catalog and per subscription table can be created as this type.
>
I think having it as a catalog table has drawbacks like who will clean
this ever growing table. The one thing is not clear from Alastair's
response is that he said to make subscription as a dependency of
table, if we do so, then won't it be difficult to even drop
subscription and also doesn't that sound reverse of what we want.
> >>
> >> 2. A further challenge is how to exclude these tables from publishing
> >> changes. If we support a subscription-level log history table and the
> >> user publishes ALL TABLES, the output plugin uses
> >> is_publishable_relation() to check if a table is publishable. However,
> >> applying the same logic here would require checking each subscription
> >> on the node to see if the table is designated as a conflict log
> >> history table for any subscription, which could be costly.
> >
> >
> > Checking the type of a table and/or whether a subscription object depends on it in a certain way would be a far less costly operation to add to is_publishable_relation()
> +1
>
> >
> >>
> >> 3. And one last thing is about should we consider dropping this table
> >> when we drop the subscription, I think this makes sense as we are
> >> internally creating it while creating the subscription.
> >
> >
> > Having to clean up the log table explicitly is likely to annoy users far less than having the conflict data destroyed as a side effect of another operation. I would strongly suggest leaving the table in place when the subscription is dropped.
>
> Thanks for the input, I would like to hear opinions from others as
> well here.
>
But OTOH, there could be users who want such a table to be dropped.
One possibility is that if we user provided us a pre-created table
then we leave it to user to remove the table, otherwise, we can remove
with drop subscription. BTW, did we decide that we want a
conflict-table-per-subscription or one table for all subscriptions, if
later, then I guess the problem would be that it has to be a shared
table across databases.
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-09-10 10:15 Dilip Kumar <dilipbalaut@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-09-10 10:15 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: Alastair Turner <minion@decodable.me>; shveta malik <shveta.malik@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Wed, Sep 10, 2025 at 3:25 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Mon, Sep 8, 2025 at 12:01 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Sun, Sep 7, 2025 at 1:42 PM Alastair Turner <minion@decodable.me> wrote:
> > >
> > > Hi Dilip
> > >
> > > Thanks for working on this, I think it will make conflict detection a lot more useful.
> >
> > Thanks for the suggestions, please find my reply inline.
> >
> > > On Sat, 6 Sept 2025, 10:38 Dilip Kumar, <dilipbalaut@gmail.com> wrote:
> > >>
> > >> While working on the patch, I see there are some open questions
> > >>
> > >> 1. We decided to pass the conflict history table name during
> > >> subscription creation. And it makes sense to create this table when
> > >> the CREATE SUBSCRIPTION command is executed. A potential concern is
> > >> that the subscription owner will also own this table, having full
> > >> control over it, including the ability to drop or alter its schema.
> >
> > >
> > > Typed tables and the dependency framework can address this concern. The schema of a typed table cannot be changed. If the subscription is marked as a dependency of the log table, the table cannot be dropped while the subscription exists.
> >
> > Yeah type table can be useful here, but only concern is when do we
> > create this type.
> >
>
> How about having this as a built-in type?
Here we will have to create a built-in type of type table which is I
think typcategory => 'C' and if we create this type it should be
supplied with the "typrelid" that means there should be a backing
catalog table. At least thats what I think.
> > One option is whenever we can create a catalog
> > relation say "conflict_log_history" that will create a type and then
> > for each subscription if we need to create the conflict history table
> > we can create it as "conflict_log_history" type, but this might not be
> > a best option as we are creating catalog just for using this type.
> > Second option is to create a type while creating a table itself but
> > then again the problem remains the same as subscription owners get
> > control over altering the schema of the type itself. So the goal is
> > we want this type to be created such that it can not be altered so
> > IMHO option1 is more suitable i.e. creating conflict_log_history as
> > catalog and per subscription table can be created as this type.
> >
>
> I think having it as a catalog table has drawbacks like who will clean
> this ever growing table.
No, I didn't mean an ever growing catalog table, I was giving an
option to create a catalog table just to create a built-in type and
then we will create an actual log history table of this built-in type
for each subscription while creating the subscription. So this
catalog table will be there but nothing will be inserted to this table
and whenever the user supplies a conflict log history table name while
creating a subscription that time we will create an actual table and
the type of the table will be as the catalog table type. I agree
creating a catalog table for this purpose might not be worth it, but I
am not yet able to figure out how to create a built-in type of type
table without creating the actual table.
The one thing is not clear from Alastair's
> response is that he said to make subscription as a dependency of
> table, if we do so, then won't it be difficult to even drop
> subscription and also doesn't that sound reverse of what we want.
I assume he means subscription will be dependent on the log table,
that means we can not drop the log table as subscription is dependent
on this table.
> > >>
> > >> 2. A further challenge is how to exclude these tables from publishing
> > >> changes. If we support a subscription-level log history table and the
> > >> user publishes ALL TABLES, the output plugin uses
> > >> is_publishable_relation() to check if a table is publishable. However,
> > >> applying the same logic here would require checking each subscription
> > >> on the node to see if the table is designated as a conflict log
> > >> history table for any subscription, which could be costly.
> > >
> > >
> > > Checking the type of a table and/or whether a subscription object depends on it in a certain way would be a far less costly operation to add to is_publishable_relation()
> > +1
> >
> > >
> > >>
> > >> 3. And one last thing is about should we consider dropping this table
> > >> when we drop the subscription, I think this makes sense as we are
> > >> internally creating it while creating the subscription.
> > >
> > >
> > > Having to clean up the log table explicitly is likely to annoy users far less than having the conflict data destroyed as a side effect of another operation. I would strongly suggest leaving the table in place when the subscription is dropped.
> >
> > Thanks for the input, I would like to hear opinions from others as
> > well here.
> >
>
> But OTOH, there could be users who want such a table to be dropped.
> One possibility is that if we user provided us a pre-created table
> then we leave it to user to remove the table, otherwise, we can remove
> with drop subscription.
Thanks make sense.
BTW, did we decide that we want a
> conflict-table-per-subscription or one table for all subscriptions, if
> later, then I guess the problem would be that it has to be a shared
> table across databases.
Right and I don't think there is an option to create a user defined
shared table. And I don't think there is any issue creating per
subscription conflict log history table, except that the subscription
owner should have permission to create the table in the database while
creating the subscription, but I think this is expected, either user
can get the sufficient privilege or disable the option for conflict
log history table.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-09-10 11:01 Alastair Turner <minion@decodable.me>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Alastair Turner @ 2025-09-10 11:01 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Wed, 10 Sept 2025 at 11:15, Dilip Kumar <dilipbalaut@gmail.com> wrote:
> On Wed, Sep 10, 2025 at 3:25 PM Amit Kapila <amit.kapila16@gmail.com>
> wrote:
> >
>
...
> >
> > How about having this as a built-in type?
>
> Here we will have to create a built-in type of type table which is I
> think typcategory => 'C' and if we create this type it should be
> supplied with the "typrelid" that means there should be a backing
> catalog table. At least thats what I think.
>
A compound type can be used for building a table, it's not necessary to
create a table when creating the type. In user SQL:
CREATE TYPE conflict_log_type AS (
conflictid UUID,
subid OID,
tableid OID,
conflicttype TEXT,
operationtype TEXT,
replication_origin TEXT,
remote_commit_ts TIMESTAMPTZ,
local_commit_ts TIMESTAMPTZ,
ri_key JSON,
remote_tuple JSON,
local_tuple JSON
);
CREATE TABLE my_subscription_conflicts OF conflict_log_type;
...
>
> The one thing is not clear from Alastair's
> > response is that he said to make subscription as a dependency of
> > table, if we do so, then won't it be difficult to even drop
> > subscription and also doesn't that sound reverse of what we want.
>
> I assume he means subscription will be dependent on the log table,
> that means we can not drop the log table as subscription is dependent
> on this table.
>
Yes, that's what I was proposing.
> > > >>
> > > >> 2. A further challenge is how to exclude these tables from
> publishing
> > > >> changes. If we support a subscription-level log history table and
> the
> > > >> user publishes ALL TABLES, the output plugin uses
> > > >> is_publishable_relation() to check if a table is publishable.
> However,
> > > >> applying the same logic here would require checking each
> subscription
> > > >> on the node to see if the table is designated as a conflict log
> > > >> history table for any subscription, which could be costly.
> > > >
> > > >
> > > > Checking the type of a table and/or whether a subscription object
> depends on it in a certain way would be a far less costly operation to add
> to is_publishable_relation()
> > > +1
> > >
> > > >
> > > >>
> > > >> 3. And one last thing is about should we consider dropping this
> table
> > > >> when we drop the subscription, I think this makes sense as we are
> > > >> internally creating it while creating the subscription.
> > > >
> > > >
> > > > Having to clean up the log table explicitly is likely to annoy users
> far less than having the conflict data destroyed as a side effect of
> another operation. I would strongly suggest leaving the table in place when
> the subscription is dropped.
> > >
> > > Thanks for the input, I would like to hear opinions from others as
> > > well here.
> > >
> >
> > But OTOH, there could be users who want such a table to be dropped.
> > One possibility is that if we user provided us a pre-created table
> > then we leave it to user to remove the table, otherwise, we can remove
> > with drop subscription.
>
> Thanks make sense.
>
> BTW, did we decide that we want a
> > conflict-table-per-subscription or one table for all subscriptions, if
> > later, then I guess the problem would be that it has to be a shared
> > table across databases.
>
> Right and I don't think there is an option to create a user defined
> shared table. And I don't think there is any issue creating per
> subscription conflict log history table, except that the subscription
> owner should have permission to create the table in the database while
> creating the subscription, but I think this is expected, either user
> can get the sufficient privilege or disable the option for conflict
> log history table.
>
Since subscriptions are created in a particular database, it seems
reasonable that error tables would also be created in a particular database.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-09-10 12:06 Dilip Kumar <dilipbalaut@gmail.com>
parent: Alastair Turner <minion@decodable.me>
0 siblings, 0 replies; 912+ messages in thread
From: Dilip Kumar @ 2025-09-10 12:06 UTC (permalink / raw)
To: Alastair Turner <minion@decodable.me>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Wed, Sep 10, 2025 at 4:32 PM Alastair Turner <minion@decodable.me> wrote:
>
>> Here we will have to create a built-in type of type table which is I
>> think typcategory => 'C' and if we create this type it should be
>> supplied with the "typrelid" that means there should be a backing
>> catalog table. At least thats what I think.
>
> A compound type can be used for building a table, it's not necessary to create a table when creating the type. In user SQL:
>
> CREATE TYPE conflict_log_type AS (
> conflictid UUID,
> subid OID,
> tableid OID,
> conflicttype TEXT,
> operationtype TEXT,
> replication_origin TEXT,
> remote_commit_ts TIMESTAMPTZ,
> local_commit_ts TIMESTAMPTZ,
> ri_key JSON,
> remote_tuple JSON,
> local_tuple JSON
> );
>
> CREATE TABLE my_subscription_conflicts OF conflict_log_type;
Problem is if you CREATE TYPE just before creating the table that
means subscription owners get full control over the type as well it
means they can alter the type itself. So logically this TYPE should
be a built-in type so that subscription owners do not have control to
ALTER the type but they have permission to create a table from this
type. But the problem is whenever you create a type it needs to have
corresponding relid in pg_class in fact you can just create a type as
per your example and see[1] it will get corresponding entry in
pg_class.
So the problem is if you create a user defined type it will be created
under the subscription owner and it defeats the purpose of not
allowing to alter the type OTOH if we create a built-in type it needs
to have a corresponding entry in pg_class.
So what's your proposal, create this type while creating a
subscription or as a built-in type, or anything else?
[1]
postgres[1948123]=# CREATE TYPE conflict_log_type AS (conflictid UUID);
postgres[1948123]=# select oid, typrelid, typcategory from pg_type
where typname='conflict_log_type';
oid | typrelid | typcategory
-------+----------+-------------
16386 | 16384 | C
(1 row)
postgres[1948123]=# select relname from pg_class where oid=16384;
relname
-------------------
conflict_log_type
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-09-10 19:23 Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: Bharath Rupireddy @ 2025-09-10 19:23 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Hi,
On Tue, Aug 5, 2025 at 5:24 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> Currently we log conflicts to the server's log file and updates, this
> approach has limitations, 1) Difficult to query and analyze, parsing
> plain text log files for conflict details is inefficient. 2) Lack of
> structured data, key conflict attributes (table, operation, old/new
> data, LSN, etc.) are not readily available in a structured, queryable
> format. 3) Difficult for external monitoring tools or custom
> resolution scripts to consume conflict data directly.
>
> This proposal aims to address these limitations by introducing a
> conflict log history table, providing a structured, and queryable
> record of all logical replication conflicts. This should be a
> configurable option whether to log into the conflict log history
> table, server logs or both.
+1 for the overall idea. Having an option to separate out the
conflicts helps analyze the data correctness issues and understand the
behavior of conflicts.
Parsing server logs file for analysis and debugging is a typical
requirement differently met with tools like log_fdw or capture server
logs in CSV format for parsing or do text search and analyze etc.
> This proposal has two main design questions:
> ===================================
>
> 1. How do we store conflicting tuples from different tables?
> Using a JSON column to store the row data seems like the most flexible
> solution, as it can accommodate different table schemas.
How good is storing conflicts on the table? Is it okay to generate WAL
traffic? Is it okay to physically replicate this log table to all
replicas? Is it okay to logically replicate this log table to all
subscribers and logical decoding clients? How does this table get
truncated? If truncation gets delayed, won't it unnecessarily fill up
storage?
> 2. Should this be a system table or a user table?
> a) System Table: Storing this in a system catalog is simple, but
> catalogs aren't designed for ever-growing data. While pg_large_object
> is an exception, this is not what we generally do IMHO.
> b) User Table: This offers more flexibility. We could allow a user to
> specify the table name during CREATE SUBSCRIPTION. Then we choose to
> either create the table internally or let the user create the table
> with a predefined schema.
-1 for the system table for sure.
> A potential drawback is that a user might drop or alter the table.
> However, we could mitigate this risk by simply logging a WARNING if
> the table is configured but an insertion fails.
> I am currently working on a POC patch for the same, but will post that
> once we have some thoughts on design choices.
How about streaming the conflicts in fixed format to a separate log
file other than regular postgres server log file? All the
rules/settings that apply to regular postgres server log files also
apply for conflicts server log files (rotation, GUCs, format
CSV/JSON/TEXT etc.). This way there's no additional WAL, and we don't
have to worry about drop/alter, truncate, delete, update/insert,
permission model, physical replication, logical replication, storage
space etc.
--
Bharath Rupireddy
PostgreSQL Contributors Team
RDS Open Source Databases
Amazon Web Services: https://aws.amazon.com
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-09-11 03:13 Amit Kapila <amit.kapila16@gmail.com>
parent: Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>
0 siblings, 2 replies; 912+ messages in thread
From: Amit Kapila @ 2025-09-11 03:13 UTC (permalink / raw)
To: Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; +Cc: Dilip Kumar <dilipbalaut@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, Sep 11, 2025 at 12:53 AM Bharath Rupireddy
<bharath.rupireddyforpostgres@gmail.com> wrote:
>
> On Tue, Aug 5, 2025 at 5:24 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > Currently we log conflicts to the server's log file and updates, this
> > approach has limitations, 1) Difficult to query and analyze, parsing
> > plain text log files for conflict details is inefficient. 2) Lack of
> > structured data, key conflict attributes (table, operation, old/new
> > data, LSN, etc.) are not readily available in a structured, queryable
> > format. 3) Difficult for external monitoring tools or custom
> > resolution scripts to consume conflict data directly.
> >
> > This proposal aims to address these limitations by introducing a
> > conflict log history table, providing a structured, and queryable
> > record of all logical replication conflicts. This should be a
> > configurable option whether to log into the conflict log history
> > table, server logs or both.
>
> +1 for the overall idea. Having an option to separate out the
> conflicts helps analyze the data correctness issues and understand the
> behavior of conflicts.
>
> Parsing server logs file for analysis and debugging is a typical
> requirement differently met with tools like log_fdw or capture server
> logs in CSV format for parsing or do text search and analyze etc.
>
> > This proposal has two main design questions:
> > ===================================
> >
> > 1. How do we store conflicting tuples from different tables?
> > Using a JSON column to store the row data seems like the most flexible
> > solution, as it can accommodate different table schemas.
>
> How good is storing conflicts on the table? Is it okay to generate WAL
> traffic?
>
Yesh, I think so. One would like to query conflicts and resolutions
for those conflicts at a later point to ensure consistency. BTW, if
you are worried about WAL traffic, please note conflicts shouldn't be
a very often event, so additional WAL should be okay. OTOH, if the
conflicts are frequent, anyway, the performance won't be that great as
that means there is a kind of ERROR which we have to deal by having
resolution for it.
> Is it okay to physically replicate this log table to all
> replicas?
>
Yes, that should be okay as we want the conflict_tables to be present
after failover.
Is it okay to logically replicate this log table to all
> subscribers and logical decoding clients?
>
I think we should avoid this.
> How does this table get
> truncated? If truncation gets delayed, won't it unnecessarily fill up
> storage?
>
I think it should be users responsibility to clean this table as they
better know when the data in the table is obsolete. Eventually, we can
also have some policies via options or some other way to get it
truncated. IIRC, we also discussed having these as partition tables so
that it is easy to discard data. However, for initial version, we may
want something simpler.
> > 2. Should this be a system table or a user table?
> > a) System Table: Storing this in a system catalog is simple, but
> > catalogs aren't designed for ever-growing data. While pg_large_object
> > is an exception, this is not what we generally do IMHO.
> > b) User Table: This offers more flexibility. We could allow a user to
> > specify the table name during CREATE SUBSCRIPTION. Then we choose to
> > either create the table internally or let the user create the table
> > with a predefined schema.
>
> -1 for the system table for sure.
>
> > A potential drawback is that a user might drop or alter the table.
> > However, we could mitigate this risk by simply logging a WARNING if
> > the table is configured but an insertion fails.
> > I am currently working on a POC patch for the same, but will post that
> > once we have some thoughts on design choices.
>
> How about streaming the conflicts in fixed format to a separate log
> file other than regular postgres server log file?
>
I would prefer this info to be stored in tables as it would be easy to
query them. If we use separate LOGs then we should provide some views
to query the LOG.
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-09-12 10:13 Dilip Kumar <dilipbalaut@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-09-12 10:13 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, Sep 11, 2025 at 8:43 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Thu, Sep 11, 2025 at 12:53 AM Bharath Rupireddy
> <bharath.rupireddyforpostgres@gmail.com> wrote:
> >
> > On Tue, Aug 5, 2025 at 5:24 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > Currently we log conflicts to the server's log file and updates, this
> > > approach has limitations, 1) Difficult to query and analyze, parsing
> > > plain text log files for conflict details is inefficient. 2) Lack of
> > > structured data, key conflict attributes (table, operation, old/new
> > > data, LSN, etc.) are not readily available in a structured, queryable
> > > format. 3) Difficult for external monitoring tools or custom
> > > resolution scripts to consume conflict data directly.
> > >
> > > This proposal aims to address these limitations by introducing a
> > > conflict log history table, providing a structured, and queryable
> > > record of all logical replication conflicts. This should be a
> > > configurable option whether to log into the conflict log history
> > > table, server logs or both.
> >
> > +1 for the overall idea. Having an option to separate out the
> > conflicts helps analyze the data correctness issues and understand the
> > behavior of conflicts.
> >
> > Parsing server logs file for analysis and debugging is a typical
> > requirement differently met with tools like log_fdw or capture server
> > logs in CSV format for parsing or do text search and analyze etc.
> >
> > > This proposal has two main design questions:
> > > ===================================
> > >
> > > 1. How do we store conflicting tuples from different tables?
> > > Using a JSON column to store the row data seems like the most flexible
> > > solution, as it can accommodate different table schemas.
> >
> > How good is storing conflicts on the table? Is it okay to generate WAL
> > traffic?
> >
>
> Yesh, I think so. One would like to query conflicts and resolutions
> for those conflicts at a later point to ensure consistency. BTW, if
> you are worried about WAL traffic, please note conflicts shouldn't be
> a very often event, so additional WAL should be okay. OTOH, if the
> conflicts are frequent, anyway, the performance won't be that great as
> that means there is a kind of ERROR which we have to deal by having
> resolution for it.
>
> > Is it okay to physically replicate this log table to all
> > replicas?
> >
>
> Yes, that should be okay as we want the conflict_tables to be present
> after failover.
>
> Is it okay to logically replicate this log table to all
> > subscribers and logical decoding clients?
> >
>
> I think we should avoid this.
>
> > How does this table get
> > truncated? If truncation gets delayed, won't it unnecessarily fill up
> > storage?
> >
>
> I think it should be users responsibility to clean this table as they
> better know when the data in the table is obsolete. Eventually, we can
> also have some policies via options or some other way to get it
> truncated. IIRC, we also discussed having these as partition tables so
> that it is easy to discard data. However, for initial version, we may
> want something simpler.
>
> > > 2. Should this be a system table or a user table?
> > > a) System Table: Storing this in a system catalog is simple, but
> > > catalogs aren't designed for ever-growing data. While pg_large_object
> > > is an exception, this is not what we generally do IMHO.
> > > b) User Table: This offers more flexibility. We could allow a user to
> > > specify the table name during CREATE SUBSCRIPTION. Then we choose to
> > > either create the table internally or let the user create the table
> > > with a predefined schema.
> >
> > -1 for the system table for sure.
> >
> > > A potential drawback is that a user might drop or alter the table.
> > > However, we could mitigate this risk by simply logging a WARNING if
> > > the table is configured but an insertion fails.
> > > I am currently working on a POC patch for the same, but will post that
> > > once we have some thoughts on design choices.
> >
> > How about streaming the conflicts in fixed format to a separate log
> > file other than regular postgres server log file?
> >
>
> I would prefer this info to be stored in tables as it would be easy to
> query them. If we use separate LOGs then we should provide some views
> to query the LOG.
I was looking into another thread where we provide an error table for
COPY [1], it requires the user to pre-create the error table. And
inside the COPY command we will validate the table, validation in that
context is a one-time process checking for: (1) table existence, (2)
ability to acquire a sufficient lock, (3) INSERT privileges, and (4)
matching column names and data types. This approach avoids concerns
about the user's DROP or ALTER permissions.
Our requirement for the logical replication conflict log table
differs, as we must validate the target table upon every conflict
insertion, not just at subscription creation. A more robust
alternative is to perform validation and acquire a lock on the
conflict table whenever the subscription worker starts. This prevents
modifications (like ALTER or DROP) while the worker is active. When
the worker gets restarted, we can re-validate the table and
automatically disable the conflict logging feature if validation
fails. And this can be enabled by ALTER SUBSCRIPTION by setting the
option again.
And if we want in first version we can expect user to create the table
as per the expected schema and supply it, this will avoid the need of
handling how to avoid it from publishing as it will be user's
responsibility and then in top up patches we can also allow to create
the table internally if tables doesn't exist and then we can find out
solution to avoid it from being publish when ALL TABLES are published.
Thoughts?
[1] https://www.postgresql.org/message-id/CACJufxEo-rsH5v__S3guUhDdXjakC7m7N5wj%3DmOB5rPiySBoQg%40mail.g...
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-09-13 00:44 Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
1 sibling, 0 replies; 912+ messages in thread
From: Bharath Rupireddy @ 2025-09-13 00:44 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: Dilip Kumar <dilipbalaut@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Hi,
On Wed, Sep 10, 2025 at 8:13 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> > How about streaming the conflicts in fixed format to a separate log
> > file other than regular postgres server log file?
>
> I would prefer this info to be stored in tables as it would be easy to
> query them. If we use separate LOGs then we should provide some views
> to query the LOG.
Providing views to query the conflicts LOG is the easiest way than
having tables (Probably we must provide both - logging conflicts to
tables and separate LOG files). However, wanting the conflicts logs
after failovers is something that makes me think the table approach is
better. I'm open to more thoughts here.
--
Bharath Rupireddy
PostgreSQL Contributors Team
RDS Open Source Databases
Amazon Web Services: https://aws.amazon.com
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-09-13 00:45 Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Bharath Rupireddy @ 2025-09-13 00:45 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Hi,
On Fri, Sep 12, 2025 at 3:13 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> I was looking into another thread where we provide an error table for
> COPY [1], it requires the user to pre-create the error table. And
> inside the COPY command we will validate the table, validation in that
> context is a one-time process checking for: (1) table existence, (2)
> ability to acquire a sufficient lock, (3) INSERT privileges, and (4)
> matching column names and data types. This approach avoids concerns
> about the user's DROP or ALTER permissions.
>
> Our requirement for the logical replication conflict log table
> differs, as we must validate the target table upon every conflict
> insertion, not just at subscription creation. A more robust
> alternative is to perform validation and acquire a lock on the
> conflict table whenever the subscription worker starts. This prevents
> modifications (like ALTER or DROP) while the worker is active. When
> the worker gets restarted, we can re-validate the table and
> automatically disable the conflict logging feature if validation
> fails. And this can be enabled by ALTER SUBSCRIPTION by setting the
> option again.
Having to worry about ALTER/DROP and adding code to protect seems like
an overkill.
> And if we want in first version we can expect user to create the table
> as per the expected schema and supply it, this will avoid the need of
> handling how to avoid it from publishing as it will be user's
> responsibility and then in top up patches we can also allow to create
> the table internally if tables doesn't exist and then we can find out
> solution to avoid it from being publish when ALL TABLES are published.
This looks much more simple to start with.
--
Bharath Rupireddy
PostgreSQL Contributors Team
RDS Open Source Databases
Amazon Web Services: https://aws.amazon.com
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-09-14 06:53 Dilip Kumar <dilipbalaut@gmail.com>
parent: Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-09-14 06:53 UTC (permalink / raw)
To: Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Sat, Sep 13, 2025 at 6:16 AM Bharath Rupireddy
<bharath.rupireddyforpostgres@gmail.com> wrote:
Thanks for the feedback Bharath
> On Fri, Sep 12, 2025 at 3:13 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > I was looking into another thread where we provide an error table for
> > COPY [1], it requires the user to pre-create the error table. And
> > inside the COPY command we will validate the table, validation in that
> > context is a one-time process checking for: (1) table existence, (2)
> > ability to acquire a sufficient lock, (3) INSERT privileges, and (4)
> > matching column names and data types. This approach avoids concerns
> > about the user's DROP or ALTER permissions.
> >
> > Our requirement for the logical replication conflict log table
> > differs, as we must validate the target table upon every conflict
> > insertion, not just at subscription creation. A more robust
> > alternative is to perform validation and acquire a lock on the
> > conflict table whenever the subscription worker starts. This prevents
> > modifications (like ALTER or DROP) while the worker is active. When
> > the worker gets restarted, we can re-validate the table and
> > automatically disable the conflict logging feature if validation
> > fails. And this can be enabled by ALTER SUBSCRIPTION by setting the
> > option again.
>
> Having to worry about ALTER/DROP and adding code to protect seems like
> an overkill.
IMHO eventually if we can control that I feel this is a good goal to
have. So that we can avoid failure during conflict insertion. We may
argue its user's responsibility to not alter the table and we can just
check the validity during create/alter subscription.
> > And if we want in first version we can expect user to create the table
> > as per the expected schema and supply it, this will avoid the need of
> > handling how to avoid it from publishing as it will be user's
> > responsibility and then in top up patches we can also allow to create
> > the table internally if tables doesn't exist and then we can find out
> > solution to avoid it from being publish when ALL TABLES are published.
>
> This looks much more simple to start with.
Right.
PFA, attached WIP patches, 0001 allow user created tables to provide
as input for conflict history tables and we will validate the table
during create/alter subscription. 0002 add an option to internally
create the table if it does not exist.
TODO:
- Still patches are WIP and need more work testing for different failure cases
- Need to explore an option to create a built-in type (I will start a
separate thread for the same)
- Need to add test cases
- Need to explore options to avoid getting published, but maybe we
only need to avoid this when we internally create the table?
Here is some basic test I tried:
psql -d postgres -c "CREATE TABLE test(a int, b int, primary key(a));"
psql -d postgres -p 5433 -c "CREATE SCHEMA myschema"
psql -d postgres -p 5433 -c "CREATE TABLE test(a int, b int, primary key(a));"
psql -d postgres -p 5433 -c "GRANT INSERT, UPDATE, SELECT, DELETE ON
test TO dk "
psql -d postgres -c "CREATE PUBLICATION pub FOR ALL TABLES ;"
psql -d postgres -p 5433 -c "CREATE SUBSCRIPTION sub CONNECTION
'dbname=postgres port=5432' PUBLICATION pub
WITH(conflict_log_table=myschema.conflict_log_history)";
psql -d postgres -p 5432 -c "INSERT INTO test VALUES(1,2);"
psql -d postgres -p 5433 -c "UPDATE test SET b=10 WHERE a=1;"
psql -d postgres -p 5432 -c "UPDATE test SET b=20 WHERE a=1;"
postgres[1202034]=# select * from myschema.conflict_log_history ;
-[ RECORD 1 ]-----+------------------------------
relid | 16385
local_xid | 763
remote_xid | 757
local_lsn | 0/00000000
remote_commit_lsn | 0/0174AB30
local_commit_ts | 2025-09-14 06:45:00.828874+00
remote_commit_ts | 2025-09-14 06:45:05.845614+00
table_schema | public
table_name | test
conflict_type | update_origin_differs
local_origin |
remote_origin | pg_16396
key_tuple | {"a":1,"b":20}
local_tuple | {"a":1,"b":10}
remote_tuple | {"a":1,"b":20}
--
Regards,
Dilip Kumar
Google
Attachments:
[application/octet-stream] v1-0002-Create-conflict-history-table-if-it-does-not-exis.patch (5.5K, ../../CAFiTN-s9WWLOhW1TO27NtJwGf0bh2+MWyp3NEkZFeN_S5_p_rA@mail.gmail.com/2-v1-0002-Create-conflict-history-table-if-it-does-not-exis.patch)
download | inline diff:
From 711e05c4ae42d415fc13e2a6983a05fb8eeec154 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Sun, 14 Sep 2025 12:13:40 +0530
Subject: [PATCH v1 2/2] Create conflict history table if it does not exist
---
src/backend/commands/subscriptioncmds.c | 83 +++++++++++++++++++++----
1 file changed, 70 insertions(+), 13 deletions(-)
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index c2f2fdabadb..f919d357a7e 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -123,7 +123,9 @@ static List *merge_publications(List *oldpublist, List *newpublist, bool addpub,
static void ReportSlotConnectionError(List *rstates, Oid subid, char *slotname, char *err);
static void CheckAlterSubOption(Subscription *sub, const char *option,
bool slot_needs_update, bool isTopLevel);
-static void ValidateConflictHistoryTable(Oid namespaceId, char *conflictrel);
+static void CreateConflictHistoryTable(Oid namespaceId, char *conflictrel);
+static void ValidateConflictHistoryTable(Oid namespaceId, char *conflictrel,
+ Oid relid);
/*
* Common option parsing function for CREATE and ALTER SUBSCRIPTION commands.
@@ -576,6 +578,54 @@ publicationListToArray(List *publist)
return PointerGetDatum(arr);
}
+/*
+ * CreateConflictHistoryTable: Create conflict log history table.
+ *
+ * The subscription creator becomes the owner of this table and has all
+ * privileges on it.
+ */
+static void
+CreateConflictHistoryTable(Oid namespaceId, char *conflictrel)
+{
+ StringInfoData querybuf;
+
+ initStringInfo(&querybuf);
+
+ /*
+ * Build and execute the CREATE TABLE query.
+ */
+ appendStringInfo(&querybuf,
+ "CREATE TABLE %s.%s ("
+ "relid Oid," /* Oid of relation */
+ "local_xid xid," /* local xid at the time of conflict */
+ "remote_xid xid," /* remote node xid that produced the conflicting change */
+ "local_lsn pg_lsn," /* local lsn at the time of conflict */
+ "remote_commit_lsn pg_lsn," /* commit lsn of the remote transaction */
+ "local_commit_ts TIMESTAMPTZ," /* commit ts of the local tuple */
+ "remote_commit_ts TIMESTAMPTZ," /* commit ts of the remote tuple */
+ "table_schema TEXT," /* name of the schema */
+ "table_name TEXT," /* name of the table */
+ "conflict_type TEXT," /* conflict type */
+ "local_origin TEXT," /* origin of remote tuple */
+ "remote_origin TEXT," /* origin of remote tuple */
+ "key_tuple JSON," /* json representation of the key used for searching */
+ "local_tuple JSON," /* json representation of the local tuple */
+ "remote_tuple JSON)", /* json representation of the remote tuple */
+ quote_identifier(get_namespace_name(namespaceId)),
+ quote_identifier(conflictrel));
+
+ if (SPI_connect() != SPI_OK_CONNECT)
+ elog(ERROR, "SPI_connect failed");
+
+ if (SPI_execute(querybuf.data, false, 0) != SPI_OK_UTILITY)
+ elog(ERROR, "SPI_exec failed: %s", querybuf.data);
+
+ if (SPI_finish() != SPI_OK_FINISH)
+ elog(ERROR, "SPI_finish failed");
+
+ pfree(querybuf.data);
+}
+
/*
* ValidateConflictHistoryTable - Validate conflict history table
*
@@ -583,7 +633,8 @@ publicationListToArray(List *publist)
* conflict log history table.
*/
static void
-ValidateConflictHistoryTable(Oid namespaceId, char *conflictrel)
+ValidateConflictHistoryTable(Oid namespaceId, char *conflictrel,
+ Oid relid)
{
Datum value;
Relation pg_attribute;
@@ -592,17 +643,9 @@ ValidateConflictHistoryTable(Oid namespaceId, char *conflictrel)
ScanKeyData scankey;
SysScanDesc scan;
HeapTuple atup;
- Oid relid;
int attcnt = 0;
bool tbl_ok = true;
- relid = get_relname_relid(conflictrel, namespaceId);
- if (!OidIsValid(relid))
- ereport(ERROR,
- errcode(ERRCODE_UNDEFINED_TABLE),
- errmsg("relation \"%s.%s\" does not exist",
- get_namespace_name(namespaceId), conflictrel));
-
/* log history table must be a regular realtion */
if (get_rel_relkind(relid) != RELKIND_RELATION)
ereport(ERROR,
@@ -959,8 +1002,16 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
/* If conflict log history table name is given than create the table. */
if (opts.conflicttable)
- ValidateConflictHistoryTable(conflict_table_nspid,
- conflict_table);
+ {
+ Oid relid = get_relname_relid(conflict_table, conflict_table_nspid);
+
+ if (!OidIsValid(relid))
+ CreateConflictHistoryTable(conflict_table_nspid, conflict_table);
+ else
+ ValidateConflictHistoryTable(conflict_table_nspid,
+ conflict_table, relid);
+ }
+
/*
* Connect to remote side to execute requested commands and fetch table
* info.
@@ -1753,6 +1804,7 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_TABLE))
{
Oid nspid;
+ Oid relid;
char *relname = NULL;
List *names =
stringToQualifiedNameList(opts.conflicttable, NULL);
@@ -1766,7 +1818,12 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
replaces[Anum_pg_subscription_subconflictnspid - 1] = true;
replaces[Anum_pg_subscription_subconflicttable - 1] = true;
- ValidateConflictHistoryTable(nspid, relname);
+ relid = get_relname_relid(relname, nspid);
+
+ if (!OidIsValid(relid))
+ CreateConflictHistoryTable(nspid, relname);
+ else
+ ValidateConflictHistoryTable(nspid, relname, relid);
}
update_tuple = true;
--
2.49.0
[application/octet-stream] v1-0001-Add-configurable-conflict-log-history-table-for-L.patch (26.1K, ../../CAFiTN-s9WWLOhW1TO27NtJwGf0bh2+MWyp3NEkZFeN_S5_p_rA@mail.gmail.com/3-v1-0001-Add-configurable-conflict-log-history-table-for-L.patch)
download | inline diff:
From dada5379d323dc91aaf3a27fb64012dc41d5962c Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Sun, 14 Sep 2025 08:38:22 +0530
Subject: [PATCH v1 1/2] Add configurable conflict log history table for
Logical Replication
This patch adds a feature to provide a structured, queryable record of all
logical replication conflicts. The current approach of logging conflicts as
plain text in the server logs makes it difficult to query, analyze, and
use for external monitoring and automation.
This patch addresses these limitations by introducing a configurable
conflict_log_table option in the CREATE SUBSCRIPTION command. Key design
decisions include:
User-Defined Table: The conflict log is stored in a user-managed table
rather than a system catalog.
Structured Data: Conflict details, including the original and remote tuples,
are stored in JSON columns, providing a flexible format to accommodate different
table schemas.
Comprehensive Information: The log table captures essential attributes such as
local and remote transaction IDs, LSNs, commit timestamps, and conflict type,
providing a complete record for post-mortem analysis.
This feature will make logical replication conflicts easier to monitor and manage,
significantly improving the overall resilience and operability of replication setups.
Open Issues:
------------
1) Need to control publishing this table when publish ALL TABLES, or maybe we can leave it to
user to EXLUDE this.
2) Currently subscription creation owner will have full control on this table as we are
creating the table during create subscription, so they can alter or drop the table which can
cause error while inserting into the conflict table.
3) If we can create a buit-in type can create conflict table of that type then we can control
altering the table. But currently we are still exploring how to create a built-in type without
creating a table, maybe add create type command in some scripts which get executed during initdb.
---
src/backend/commands/subscriptioncmds.c | 248 ++++++++++++++++++++-
src/backend/replication/logical/conflict.c | 188 ++++++++++++++++
src/backend/replication/logical/worker.c | 10 +-
src/backend/utils/cache/lsyscache.c | 45 ++++
src/include/catalog/pg_subscription.h | 5 +
src/include/replication/conflict.h | 2 +
src/include/replication/worker_internal.h | 4 +
src/include/utils/lsyscache.h | 1 +
8 files changed, 499 insertions(+), 4 deletions(-)
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index 750d262fcca..c2f2fdabadb 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -34,6 +34,7 @@
#include "commands/event_trigger.h"
#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "executor/spi.h"
#include "miscadmin.h"
#include "nodes/makefuncs.h"
#include "pgstat.h"
@@ -47,10 +48,12 @@
#include "storage/lmgr.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/guc.h"
#include "utils/lsyscache.h"
#include "utils/memutils.h"
#include "utils/pg_lsn.h"
+#include "utils/regproc.h"
#include "utils/syscache.h"
/*
@@ -75,6 +78,7 @@
#define SUBOPT_MAX_RETENTION_DURATION 0x00008000
#define SUBOPT_LSN 0x00010000
#define SUBOPT_ORIGIN 0x00020000
+#define SUBOPT_CONFLICT_TABLE 0x00030000
/* check if the 'val' has 'bits' set */
#define IsSet(val, bits) (((val) & (bits)) == (bits))
@@ -103,6 +107,7 @@ typedef struct SubOpts
bool retaindeadtuples;
int32 maxretention;
char *origin;
+ char *conflicttable;
XLogRecPtr lsn;
} SubOpts;
@@ -118,7 +123,7 @@ static List *merge_publications(List *oldpublist, List *newpublist, bool addpub,
static void ReportSlotConnectionError(List *rstates, Oid subid, char *slotname, char *err);
static void CheckAlterSubOption(Subscription *sub, const char *option,
bool slot_needs_update, bool isTopLevel);
-
+static void ValidateConflictHistoryTable(Oid namespaceId, char *conflictrel);
/*
* Common option parsing function for CREATE and ALTER SUBSCRIPTION commands.
@@ -174,6 +179,8 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->maxretention = 0;
if (IsSet(supported_opts, SUBOPT_ORIGIN))
opts->origin = pstrdup(LOGICALREP_ORIGIN_ANY);
+ if (IsSet(supported_opts, SUBOPT_CONFLICT_TABLE))
+ opts->conflicttable = NULL;
/* Parse options */
foreach(lc, stmt_options)
@@ -385,6 +392,15 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->specified_opts |= SUBOPT_LSN;
opts->lsn = lsn;
}
+ else if (IsSet(supported_opts, SUBOPT_CONFLICT_TABLE) &&
+ strcmp(defel->defname, "conflict_log_table") == 0)
+ {
+ if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_TABLE))
+ errorConflictingDefElem(defel, pstate);
+
+ opts->specified_opts |= SUBOPT_CONFLICT_TABLE;
+ opts->conflicttable = defGetString(defel);
+ }
else
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
@@ -560,6 +576,186 @@ publicationListToArray(List *publist)
return PointerGetDatum(arr);
}
+/*
+ * ValidateConflictHistoryTable - Validate conflict history table
+ *
+ * Validate whether the input 'conflictrel' is suitable for considering as
+ * conflict log history table.
+ */
+static void
+ValidateConflictHistoryTable(Oid namespaceId, char *conflictrel)
+{
+ Datum value;
+ Relation pg_attribute;
+ Relation rel;
+ Form_pg_attribute attForm;
+ ScanKeyData scankey;
+ SysScanDesc scan;
+ HeapTuple atup;
+ Oid relid;
+ int attcnt = 0;
+ bool tbl_ok = true;
+
+ relid = get_relname_relid(conflictrel, namespaceId);
+ if (!OidIsValid(relid))
+ ereport(ERROR,
+ errcode(ERRCODE_UNDEFINED_TABLE),
+ errmsg("relation \"%s.%s\" does not exist",
+ get_namespace_name(namespaceId), conflictrel));
+
+ /* log history table must be a regular realtion */
+ if (get_rel_relkind(relid) != RELKIND_RELATION)
+ ereport(ERROR,
+ errcode(ERRCODE_WRONG_OBJECT_TYPE),
+ errmsg("cannot use relation \"%s.%s\" for storing conflict log",
+ get_namespace_name(namespaceId), conflictrel),
+ errdetail_relkind_not_supported(get_rel_relkind(relid)));
+
+ /*
+ * We might insert tuples into the conflict log history table later, so we
+ * first need to check its lock status. If it is already heavily locked,
+ * our subsequent COPY operation may stuck. Instead of letting COPY FROM
+ * hang, report an error indicating that the error-saving table is under
+ * heavy lock.
+ */
+ if (!ConditionalLockRelationOid(relid, RowExclusiveLock))
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_IN_USE),
+ errmsg("can not use table \"%s.%s\" for storing conflict log because it was being locked",
+ get_namespace_name(namespaceId), conflictrel));
+
+ rel = table_open(relid, RowExclusiveLock);
+
+ /* The user should have INSERT privilege on conflict history table */
+ value = DirectFunctionCall3(has_table_privilege_id_id,
+ GetUserId(),
+ ObjectIdGetDatum(relid),
+ CStringGetTextDatum("INSERT"));
+ if (!DatumGetBool(value))
+ ereport(ERROR,
+ errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+ errmsg("permission denied to set table \"%s\" as conflict log history table",
+ RelationGetRelationName(rel)),
+ errhint("Ensure current user have enough privilege on \"%s.%s\" for conflict log history table",
+ get_namespace_name(namespaceId), conflictrel));
+
+ /*
+ * Check whether the table definition (conflictrel) including its column
+ * names, data types, and column ordering meets the requirements for conflict
+ * log history table
+ */
+ pg_attribute = table_open(AttributeRelationId, AccessShareLock);
+ ScanKeyInit(&scankey,
+ Anum_pg_attribute_attrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(relid));
+
+ scan = systable_beginscan(pg_attribute, AttributeRelidNumIndexId, true,
+ SnapshotSelf, 1, &scankey);
+ while (HeapTupleIsValid(atup = systable_getnext(scan)) && tbl_ok)
+ {
+ attForm = (Form_pg_attribute) GETSTRUCT(atup);
+
+ if (attForm->attnum < 1 || attForm->attisdropped)
+ continue;
+
+ attcnt++;
+ switch (attForm->attnum)
+ {
+ case 1:
+ if (attForm->atttypid != OIDOID ||
+ strcmp(NameStr(attForm->attname), "relid") != 0)
+ tbl_ok = false;
+ break;
+ case 2:
+ if (attForm->atttypid != XIDOID ||
+ strcmp(NameStr(attForm->attname), "local_xid") != 0)
+ tbl_ok = false;
+ break;
+ case 3:
+ if (attForm->atttypid != XIDOID ||
+ strcmp(NameStr(attForm->attname), "remote_xid") != 0)
+ tbl_ok = false;
+ break;
+ case 4:
+ if (attForm->atttypid != LSNOID ||
+ strcmp(NameStr(attForm->attname), "local_lsn") != 0)
+ tbl_ok = false;
+ break;
+ case 5:
+ if (attForm->atttypid != LSNOID ||
+ strcmp(NameStr(attForm->attname), "remote_commit_lsn") != 0)
+ tbl_ok = false;
+ break;
+ case 6:
+ if (attForm->atttypid != TIMESTAMPTZOID ||
+ strcmp(NameStr(attForm->attname), "local_commit_ts") != 0)
+ tbl_ok = false;
+ break;
+ case 7:
+ if (attForm->atttypid != TIMESTAMPTZOID ||
+ strcmp(NameStr(attForm->attname), "remote_commit_ts") != 0)
+ tbl_ok = false;
+ break;
+ case 8:
+ if (attForm->atttypid != TEXTOID ||
+ strcmp(NameStr(attForm->attname), "table_schema") != 0)
+ tbl_ok = false;
+ break;
+ case 9:
+ if (attForm->atttypid != TEXTOID ||
+ strcmp(NameStr(attForm->attname), "table_name") != 0)
+ tbl_ok = false;
+ break;
+ case 10:
+ if (attForm->atttypid != TEXTOID ||
+ strcmp(NameStr(attForm->attname), "conflict_type") != 0)
+ tbl_ok = false;
+ break;
+ case 11:
+ if (attForm->atttypid != TEXTOID ||
+ strcmp(NameStr(attForm->attname), "local_origin") != 0)
+ tbl_ok = false;
+ break;
+ case 12:
+ if (attForm->atttypid != TEXTOID ||
+ strcmp(NameStr(attForm->attname), "remote_origin") != 0)
+ tbl_ok = false;
+ break;
+ case 13:
+ if (attForm->atttypid != JSONOID ||
+ strcmp(NameStr(attForm->attname), "key_tuple") != 0)
+ tbl_ok = false;
+ break;
+ case 14:
+ if (attForm->atttypid != JSONOID ||
+ strcmp(NameStr(attForm->attname), "local_tuple") != 0)
+ tbl_ok = false;
+ break;
+ case 15:
+ if (attForm->atttypid != JSONOID ||
+ strcmp(NameStr(attForm->attname), "remote_tuple") != 0)
+ tbl_ok = false;
+ break;
+ default:
+ tbl_ok = false;
+ break;
+ }
+ }
+ systable_endscan(scan);
+ table_close(pg_attribute, AccessShareLock);
+
+ if (attcnt != MAX_CONFLICT_ATTR_NUM || !tbl_ok)
+ ereport(ERROR,
+ errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("table \"%s.%s\" cannot be used for storing conflict log",
+ get_namespace_name(namespaceId), conflictrel),
+ errdetail("Table \"%s.%s\" data definition is not suitable for storing conflict log",
+ get_namespace_name(namespaceId), conflictrel));
+
+ table_close(rel, RowExclusiveLock);
+}
+
/*
* Create new subscription.
*/
@@ -580,6 +776,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bits32 supported_opts;
SubOpts opts = {0};
AclResult aclresult;
+ Oid conflict_table_nspid;
+ char *conflict_table;
/*
* Parse and check options.
@@ -593,7 +791,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
SUBOPT_DISABLE_ON_ERR | SUBOPT_PASSWORD_REQUIRED |
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
- SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN);
+ SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_TABLE);
parse_subscription_options(pstate, stmt->options, supported_opts, &opts);
/*
@@ -728,6 +927,25 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
values[Anum_pg_subscription_suborigin - 1] =
CStringGetTextDatum(opts.origin);
+ /*
+ * If a conflict log history table name is specified, parse the schema and
+ * table name from the string. Store the namespace OID and the table name in
+ * the pg_subscription catalog tuple.
+ */
+ if (opts.conflicttable)
+ {
+ List *names = stringToQualifiedNameList(opts.conflicttable, NULL);
+
+ conflict_table_nspid =
+ QualifiedNameGetCreationNamespace(names, &conflict_table);
+ values[Anum_pg_subscription_subconflictnspid - 1] =
+ ObjectIdGetDatum(conflict_table_nspid);
+ values[Anum_pg_subscription_subconflicttable - 1] =
+ CStringGetTextDatum(conflict_table);
+ }
+ else
+ nulls[Anum_pg_subscription_subconflicttable - 1] = true;
+
tup = heap_form_tuple(RelationGetDescr(rel), values, nulls);
/* Insert tuple into catalog. */
@@ -739,6 +957,10 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
ReplicationOriginNameForLogicalRep(subid, InvalidOid, originname, sizeof(originname));
replorigin_create(originname);
+ /* If conflict log history table name is given than create the table. */
+ if (opts.conflicttable)
+ ValidateConflictHistoryTable(conflict_table_nspid,
+ conflict_table);
/*
* Connect to remote side to execute requested commands and fetch table
* info.
@@ -1272,7 +1494,8 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
SUBOPT_MAX_RETENTION_DURATION |
- SUBOPT_ORIGIN);
+ SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_TABLE);
parse_subscription_options(pstate, stmt->options,
supported_opts, &opts);
@@ -1527,6 +1750,25 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
origin = opts.origin;
}
+ if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_TABLE))
+ {
+ Oid nspid;
+ char *relname = NULL;
+ List *names =
+ stringToQualifiedNameList(opts.conflicttable, NULL);
+
+ nspid = QualifiedNameGetCreationNamespace(names, &relname);
+ values[Anum_pg_subscription_subconflictnspid - 1] =
+ ObjectIdGetDatum(nspid);
+ values[Anum_pg_subscription_subconflicttable - 1] =
+ CStringGetTextDatum(relname);
+
+ replaces[Anum_pg_subscription_subconflictnspid - 1] = true;
+ replaces[Anum_pg_subscription_subconflicttable - 1] = true;
+
+ ValidateConflictHistoryTable(nspid, relname);
+ }
+
update_tuple = true;
break;
}
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 16695592265..89ff4c33f11 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -15,13 +15,23 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/tableam.h"
+#include "access/table.h"
+#include "catalog/indexing.h"
+#include "catalog/namespace.h"
+#include "catalog/pg_namespace_d.h"
+#include "catalog/pg_type.h"
#include "executor/executor.h"
+#include "executor/spi.h"
#include "pgstat.h"
#include "replication/conflict.h"
#include "replication/worker_internal.h"
#include "storage/lmgr.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/lsyscache.h"
+#include "utils/pg_lsn.h"
static const char *const ConflictTypeNames[] = {
[CT_INSERT_EXISTS] = "insert_exists",
@@ -85,6 +95,174 @@ GetTupleTransactionInfo(TupleTableSlot *localslot, TransactionId *xmin,
return TransactionIdGetCommitTsData(*xmin, localts, localorigin);
}
+/*
+ * Helper function to convert a TupleTableSlot to Jsonb
+ *
+ * This would be a new internal helper function for logical replication
+ * Needs to handle various data types and potentially TOASTed data
+ */
+static Datum
+TupleTableSlotToJsonDatum(TupleTableSlot *slot)
+{
+ HeapTuple tuple = ExecCopySlotHeapTuple(slot);
+ Datum datum = heap_copy_tuple_as_datum(tuple, slot->tts_tupleDescriptor);
+ Datum json;
+
+ if (TupIsNull(slot))
+ return 0;
+
+ json = DirectFunctionCall1(row_to_json, datum);
+ heap_freetuple(tuple);
+
+ return json;
+}
+
+/*
+ * InsertIntoLogHistoryTable
+ *
+ * Logs details about a logical replication conflict to a conflict history
+ * table.
+ */
+static void
+InsertIntoLogHistoryTable(Relation rel, TransactionId local_xid,
+ TimestampTz local_ts, ConflictType conflict_type,
+ RepOriginId origin_id, TupleTableSlot *searchslot,
+ TupleTableSlot *localslot,
+ TupleTableSlot *remoteslot)
+{
+ Datum values[MAX_CONFLICT_ATTR_NUM];
+ char nulls[MAX_CONFLICT_ATTR_NUM];
+ Oid argtypes[MAX_CONFLICT_ATTR_NUM];
+ int attno;
+ char *origin = NULL;
+ char *conflict_rel;
+ char *remote_origin = NULL;
+ XLogRecPtr local_lsn = 0;
+ StringInfoData querybuf;
+
+
+ /* If conflict history is not enabled for the subscription just return. */
+ conflict_rel = get_subscription_conflictrel(MyLogicalRepWorker->subid);
+ if (conflict_rel == NULL)
+ return;
+
+ /* Initialize values and nulls arrays */
+ memset(values, 0, sizeof(Datum) * MAX_CONFLICT_ATTR_NUM);
+ memset(nulls, ' ', sizeof(char) * MAX_CONFLICT_ATTR_NUM);
+
+ /* Populate the values and nulls arrays */
+ attno = 0;
+ argtypes[attno] = OIDOID;
+ values[attno] = ObjectIdGetDatum(RelationGetRelid(rel));
+ attno++;
+
+ argtypes[attno] = XIDOID;
+ values[attno] = TransactionIdGetDatum(local_xid);
+ attno++;
+
+ argtypes[attno] = XIDOID;
+ values[attno] = TransactionIdGetDatum(remote_xid);
+ attno++;
+
+ argtypes[attno] = LSNOID;
+ values[attno] = LSNGetDatum(local_lsn);
+ attno++;
+
+ argtypes[attno] = LSNOID;
+ values[attno] = LSNGetDatum(remote_final_lsn);
+ attno++;
+
+ argtypes[attno] = TIMESTAMPTZOID;
+ values[attno] = TimestampTzGetDatum(local_ts);
+ attno++;
+
+ argtypes[attno] = TIMESTAMPTZOID;
+ values[attno] = TimestampTzGetDatum(remote_commit_ts);
+ attno++;
+
+ argtypes[attno] = TEXTOID;
+ values[attno] =
+ CStringGetTextDatum(get_namespace_name(RelationGetNamespace(rel)));
+ attno++;
+
+ argtypes[attno] = TEXTOID;
+ values[attno] = CStringGetTextDatum(RelationGetRelationName(rel));
+ attno++;
+
+ argtypes[attno] = TEXTOID;
+ values[attno] = CStringGetTextDatum(ConflictTypeNames[conflict_type]);
+ attno++;
+
+ if (origin_id != InvalidRepOriginId)
+ replorigin_by_oid(origin_id, true, &origin);
+
+ argtypes[attno] = TEXTOID;
+ if (origin != NULL)
+ values[attno] = CStringGetTextDatum(origin);
+ else
+ nulls[attno] = 'n';
+ attno++;
+
+ if (replorigin_session_origin != InvalidRepOriginId)
+ replorigin_by_oid(replorigin_session_origin, true, &remote_origin);
+
+ argtypes[attno] = TEXTOID;
+ if (remote_origin != NULL)
+ values[attno] = CStringGetTextDatum(remote_origin);
+ else
+ nulls[attno] = 'n';
+ attno++;
+
+ argtypes[attno] = JSONOID;
+ if (searchslot != NULL)
+ values[attno] = TupleTableSlotToJsonDatum(searchslot);
+ else
+ nulls[attno] = 'n';
+ attno++;
+
+ argtypes[attno] = JSONOID;
+ if (localslot != NULL)
+ values[attno] = TupleTableSlotToJsonDatum(localslot);
+ else
+ nulls[attno] = 'n';
+ attno++;
+
+ argtypes[attno] = JSONOID;
+ if (remoteslot != NULL)
+ values[attno] = TupleTableSlotToJsonDatum(remoteslot);
+ else
+ nulls[attno] = 'n';
+
+ /* Prepare a insert query. */
+ initStringInfo(&querybuf);
+ appendStringInfo(&querybuf,
+ "INSERT INTO %s VALUES ("
+ "$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,"
+ "$14, $15)",
+ conflict_rel);
+
+ if (SPI_connect() != SPI_OK_CONNECT)
+ elog(ERROR, "SPI_connect failed");
+
+ /*
+ * XXX The following section uses SPI to execute the INSERT. If the\
+ * insertion fails, we currently throw an ERROR. A future improvement might
+ * be to log a WARNING instead, to avoid aborting the entire replication
+ * worker on a logging failure.
+ */
+ if (SPI_execute_with_args(querybuf.data,
+ MAX_CONFLICT_ATTR_NUM, argtypes,
+ values, nulls,
+ false, 0) != SPI_OK_INSERT)
+ elog(ERROR, "SPI_execute_with_args failed: %s", querybuf.data);
+
+ if (SPI_finish() != SPI_OK_FINISH)
+ elog(ERROR, "SPI_finish failed");
+
+ pfree(querybuf.data);
+ pfree(conflict_rel);
+}
+
/*
* This function is used to report a conflict while applying replication
* changes.
@@ -112,6 +290,7 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
/* Form errdetail message by combining conflicting tuples information. */
foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
+ {
errdetail_apply_conflict(estate, relinfo, type, searchslot,
conflicttuple->slot, remoteslot,
conflicttuple->indexoid,
@@ -120,6 +299,15 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
conflicttuple->ts,
&err_detail);
+ /* Insert conflict details to log history table. */
+ InsertIntoLogHistoryTable(relinfo->ri_RelationDesc,
+ conflicttuple->xmin,
+ conflicttuple->ts, type,
+ conflicttuple->origin,
+ searchslot, conflicttuple->slot,
+ remoteslot);
+ }
+
pgstat_report_subscription_conflict(MySubscription->oid, type);
ereport(elevel,
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index ee6ac22329f..3d119a255fd 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -472,7 +472,9 @@ static bool MySubscriptionValid = false;
static List *on_commit_wakeup_workers_subids = NIL;
bool in_remote_transaction = false;
-static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+TransactionId remote_xid = InvalidTransactionId;
+TimestampTz remote_commit_ts = 0;
/* fields valid only when processing streamed transaction */
static bool in_streamed_transaction = false;
@@ -1199,6 +1201,8 @@ apply_handle_begin(StringInfo s)
set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
remote_final_lsn = begin_data.final_lsn;
+ remote_commit_ts = begin_data.committime;
+ remote_xid = begin_data.xid;
maybe_start_skipping_changes(begin_data.final_lsn);
@@ -1710,6 +1714,10 @@ apply_handle_stream_start(StringInfo s)
/* extract XID of the top-level transaction */
stream_xid = logicalrep_read_stream_start(s, &first_segment);
+ remote_xid = stream_xid;
+ remote_final_lsn = InvalidXLogRecPtr;
+ remote_commit_ts = 0;
+
if (!TransactionIdIsValid(stream_xid))
ereport(ERROR,
(errcode(ERRCODE_PROTOCOL_VIOLATION),
diff --git a/src/backend/utils/cache/lsyscache.c b/src/backend/utils/cache/lsyscache.c
index fa7cd7e06a7..fcc6940bab0 100644
--- a/src/backend/utils/cache/lsyscache.c
+++ b/src/backend/utils/cache/lsyscache.c
@@ -3881,3 +3881,48 @@ get_subscription_name(Oid subid, bool missing_ok)
return subname;
}
+
+/*
+ * get_subscription_conflictrel
+ *
+ * Returns the schema-qualified name of the conflict history table.
+ * The returned string is palloc'd and must be freed by the caller.
+ *
+ */
+char *
+get_subscription_conflictrel(Oid subid)
+{
+ HeapTuple tup;
+ Datum datum;
+ bool isnull;
+ StringInfoData conflictrel;
+ Form_pg_subscription subform;
+
+ tup = SearchSysCache1(SUBSCRIPTIONOID, ObjectIdGetDatum(subid));
+
+ if (!HeapTupleIsValid(tup))
+ return NULL;
+
+ subform = (Form_pg_subscription) GETSTRUCT(tup);
+
+ /* Get conflict table name */
+ datum = SysCacheGetAttr(SUBSCRIPTIONOID,
+ tup,
+ Anum_pg_subscription_subconflicttable,
+ &isnull);
+
+ if (!isnull)
+ {
+ initStringInfo(&conflictrel);
+ appendStringInfo(&conflictrel, "%s.%s",
+ get_namespace_name(subform->subconflictnspid),
+ TextDatumGetCString(datum));
+ ReleaseSysCache(tup);
+ return conflictrel.data;
+ }
+ else
+ {
+ ReleaseSysCache(tup);
+ return NULL;
+ }
+}
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index 55cb9b1eefa..ec31e2b1d56 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -80,6 +80,8 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
bool subretaindeadtuples; /* True if dead tuples useful for
* conflict detection are retained */
+ Oid subconflictnspid; /* Namespace Oid in which the conflict history
+ * table is created. */
int32 submaxretention; /* The maximum duration (in milliseconds)
* for which information useful for
@@ -105,6 +107,9 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
/* Only publish data originating from the specified origin */
text suborigin BKI_DEFAULT(LOGICALREP_ORIGIN_ANY);
+
+ /* conflict log history table name if valid */
+ text subconflicttable;
#endif
} FormData_pg_subscription;
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index e516caa5c73..4618e3c102c 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -55,6 +55,7 @@ typedef enum
} ConflictType;
#define CONFLICT_NUM_TYPES (CT_MULTIPLE_UNIQUE_CONFLICTS + 1)
+#define MAX_CONFLICT_ATTR_NUM 15
/*
* Information for the existing local row that caused the conflict.
@@ -82,4 +83,5 @@ extern void ReportApplyConflict(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *remoteslot,
List *conflicttuples);
extern void InitConflictIndexes(ResultRelInfo *relInfo);
+
#endif
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index de003802612..84bd6383615 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -251,6 +251,10 @@ extern PGDLLIMPORT bool in_remote_transaction;
extern PGDLLIMPORT bool InitializingApplyWorker;
+extern XLogRecPtr remote_final_lsn;
+extern TimestampTz remote_commit_ts;
+extern TransactionId remote_xid;
+
extern void logicalrep_worker_attach(int slot);
extern LogicalRepWorker *logicalrep_worker_find(Oid subid, Oid relid,
bool only_running);
diff --git a/src/include/utils/lsyscache.h b/src/include/utils/lsyscache.h
index c65cee4f24c..c4dc422ce2a 100644
--- a/src/include/utils/lsyscache.h
+++ b/src/include/utils/lsyscache.h
@@ -210,6 +210,7 @@ extern Oid get_publication_oid(const char *pubname, bool missing_ok);
extern char *get_publication_name(Oid pubid, bool missing_ok);
extern Oid get_subscription_oid(const char *subname, bool missing_ok);
extern char *get_subscription_name(Oid subid, bool missing_ok);
+extern char *get_subscription_conflictrel(Oid subid);
#define type_is_array(typid) (get_element_type(typid) != InvalidOid)
/* type_is_array_domain accepts both plain arrays and domains over arrays */
--
2.49.0
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-09-18 08:33 Amit Kapila <amit.kapila16@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 2 replies; 912+ messages in thread
From: Amit Kapila @ 2025-09-18 08:33 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Sun, Sep 14, 2025 at 12:23 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Sat, Sep 13, 2025 at 6:16 AM Bharath Rupireddy
> <bharath.rupireddyforpostgres@gmail.com> wrote:
>
> Thanks for the feedback Bharath
>
> > On Fri, Sep 12, 2025 at 3:13 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > I was looking into another thread where we provide an error table for
> > > COPY [1], it requires the user to pre-create the error table. And
> > > inside the COPY command we will validate the table, validation in that
> > > context is a one-time process checking for: (1) table existence, (2)
> > > ability to acquire a sufficient lock, (3) INSERT privileges, and (4)
> > > matching column names and data types. This approach avoids concerns
> > > about the user's DROP or ALTER permissions.
> > >
> > > Our requirement for the logical replication conflict log table
> > > differs, as we must validate the target table upon every conflict
> > > insertion, not just at subscription creation. A more robust
> > > alternative is to perform validation and acquire a lock on the
> > > conflict table whenever the subscription worker starts. This prevents
> > > modifications (like ALTER or DROP) while the worker is active. When
> > > the worker gets restarted, we can re-validate the table and
> > > automatically disable the conflict logging feature if validation
> > > fails. And this can be enabled by ALTER SUBSCRIPTION by setting the
> > > option again.
> >
> > Having to worry about ALTER/DROP and adding code to protect seems like
> > an overkill.
>
> IMHO eventually if we can control that I feel this is a good goal to
> have. So that we can avoid failure during conflict insertion. We may
> argue its user's responsibility to not alter the table and we can just
> check the validity during create/alter subscription.
>
If we compare conflict_history_table with the slot that gets created
with subscription, one can say the same thing about slots. Users can
drop the slots and whole replication will stop. I think this table
will be created with the same privileges as the owner of a
subscription which can be either a superuser or a user with the
privileges of the pg_create_subscription role, so we can rely on such
users.
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-09-18 13:26 Dilip Kumar <dilipbalaut@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
1 sibling, 0 replies; 912+ messages in thread
From: Dilip Kumar @ 2025-09-18 13:26 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, Sep 18, 2025 at 2:03 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Sun, Sep 14, 2025 at 12:23 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Sat, Sep 13, 2025 at 6:16 AM Bharath Rupireddy
> > <bharath.rupireddyforpostgres@gmail.com> wrote:
> >
> > Thanks for the feedback Bharath
> >
> > > On Fri, Sep 12, 2025 at 3:13 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > >
> > > > I was looking into another thread where we provide an error table for
> > > > COPY [1], it requires the user to pre-create the error table. And
> > > > inside the COPY command we will validate the table, validation in that
> > > > context is a one-time process checking for: (1) table existence, (2)
> > > > ability to acquire a sufficient lock, (3) INSERT privileges, and (4)
> > > > matching column names and data types. This approach avoids concerns
> > > > about the user's DROP or ALTER permissions.
> > > >
> > > > Our requirement for the logical replication conflict log table
> > > > differs, as we must validate the target table upon every conflict
> > > > insertion, not just at subscription creation. A more robust
> > > > alternative is to perform validation and acquire a lock on the
> > > > conflict table whenever the subscription worker starts. This prevents
> > > > modifications (like ALTER or DROP) while the worker is active. When
> > > > the worker gets restarted, we can re-validate the table and
> > > > automatically disable the conflict logging feature if validation
> > > > fails. And this can be enabled by ALTER SUBSCRIPTION by setting the
> > > > option again.
> > >
> > > Having to worry about ALTER/DROP and adding code to protect seems like
> > > an overkill.
> >
> > IMHO eventually if we can control that I feel this is a good goal to
> > have. So that we can avoid failure during conflict insertion. We may
> > argue its user's responsibility to not alter the table and we can just
> > check the validity during create/alter subscription.
> >
>
> If we compare conflict_history_table with the slot that gets created
> with subscription, one can say the same thing about slots. Users can
> drop the slots and whole replication will stop. I think this table
> will be created with the same privileges as the owner of a
> subscription which can be either a superuser or a user with the
> privileges of the pg_create_subscription role, so we can rely on such
> users.
Yeah that's a valid point.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-09-18 18:15 Masahiko Sawada <sawada.mshk@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: Masahiko Sawada @ 2025-09-18 18:15 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: Dilip Kumar <dilipbalaut@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, Sep 18, 2025 at 1:33 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Sun, Sep 14, 2025 at 12:23 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Sat, Sep 13, 2025 at 6:16 AM Bharath Rupireddy
> > <bharath.rupireddyforpostgres@gmail.com> wrote:
> >
> > Thanks for the feedback Bharath
> >
> > > On Fri, Sep 12, 2025 at 3:13 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > >
> > > > I was looking into another thread where we provide an error table for
> > > > COPY [1], it requires the user to pre-create the error table. And
> > > > inside the COPY command we will validate the table, validation in that
> > > > context is a one-time process checking for: (1) table existence, (2)
> > > > ability to acquire a sufficient lock, (3) INSERT privileges, and (4)
> > > > matching column names and data types. This approach avoids concerns
> > > > about the user's DROP or ALTER permissions.
> > > >
> > > > Our requirement for the logical replication conflict log table
> > > > differs, as we must validate the target table upon every conflict
> > > > insertion, not just at subscription creation. A more robust
> > > > alternative is to perform validation and acquire a lock on the
> > > > conflict table whenever the subscription worker starts. This prevents
> > > > modifications (like ALTER or DROP) while the worker is active. When
> > > > the worker gets restarted, we can re-validate the table and
> > > > automatically disable the conflict logging feature if validation
> > > > fails. And this can be enabled by ALTER SUBSCRIPTION by setting the
> > > > option again.
> > >
> > > Having to worry about ALTER/DROP and adding code to protect seems like
> > > an overkill.
> >
> > IMHO eventually if we can control that I feel this is a good goal to
> > have. So that we can avoid failure during conflict insertion. We may
> > argue its user's responsibility to not alter the table and we can just
> > check the validity during create/alter subscription.
> >
>
> If we compare conflict_history_table with the slot that gets created
> with subscription, one can say the same thing about slots. Users can
> drop the slots and whole replication will stop. I think this table
> will be created with the same privileges as the owner of a
> subscription which can be either a superuser or a user with the
> privileges of the pg_create_subscription role, so we can rely on such
> users.
We might want to consider which role inserts the conflict info into
the history table. For example, if any table created by a user can be
used as the history table for a subscription and the conflict info
insertion is performed by the subscription owner, we would end up
having the same security issue that was addressed by the run_as_owner
subscription option.
Regards,
--
Masahiko Sawada
Amazon Web Services: https://aws.amazon.com
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-09-20 11:59 Amit Kapila <amit.kapila16@gmail.com>
parent: Masahiko Sawada <sawada.mshk@gmail.com>
0 siblings, 2 replies; 912+ messages in thread
From: Amit Kapila @ 2025-09-20 11:59 UTC (permalink / raw)
To: Masahiko Sawada <sawada.mshk@gmail.com>; +Cc: Dilip Kumar <dilipbalaut@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, Sep 18, 2025 at 11:46 PM Masahiko Sawada <sawada.mshk@gmail.com> wrote:
>
> On Thu, Sep 18, 2025 at 1:33 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
> >
> > If we compare conflict_history_table with the slot that gets created
> > with subscription, one can say the same thing about slots. Users can
> > drop the slots and whole replication will stop. I think this table
> > will be created with the same privileges as the owner of a
> > subscription which can be either a superuser or a user with the
> > privileges of the pg_create_subscription role, so we can rely on such
> > users.
>
> We might want to consider which role inserts the conflict info into
> the history table. For example, if any table created by a user can be
> used as the history table for a subscription and the conflict info
> insertion is performed by the subscription owner, we would end up
> having the same security issue that was addressed by the run_as_owner
> subscription option.
>
Yeah, I don't think we want to open that door. For user created
tables, we should perform actions with table_owner's privilege. In
such a case, if one wants to create a subscription with run_as_owner
option, she should give DML operation permissions to the subscription
owner. OTOH, if we create this table internally (via subscription
owner) then irrespective of run_as_owner, we will always insert as
subscription_owner.
AFAIR, one open point for internally created tables is whether we
should skip changes to conflict_history table while replicating
changes? The table will be considered under for ALL TABLES
publications, if defined? Ideally, these should behave as catalog
tables, so one option is to mark them as 'user_catalog_table', or the
other option is we have some hard-code checks during replication. The
first option has the advantage that it won't write additional WAL for
these tables which is otherwise required under wal_level=logical. What
other options do we have?
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-09-23 17:59 Masahiko Sawada <sawada.mshk@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
1 sibling, 2 replies; 912+ messages in thread
From: Masahiko Sawada @ 2025-09-23 17:59 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: Dilip Kumar <dilipbalaut@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Sat, Sep 20, 2025 at 4:59 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Thu, Sep 18, 2025 at 11:46 PM Masahiko Sawada <sawada.mshk@gmail.com> wrote:
> >
> > On Thu, Sep 18, 2025 at 1:33 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > >
> > > If we compare conflict_history_table with the slot that gets created
> > > with subscription, one can say the same thing about slots. Users can
> > > drop the slots and whole replication will stop. I think this table
> > > will be created with the same privileges as the owner of a
> > > subscription which can be either a superuser or a user with the
> > > privileges of the pg_create_subscription role, so we can rely on such
> > > users.
> >
> > We might want to consider which role inserts the conflict info into
> > the history table. For example, if any table created by a user can be
> > used as the history table for a subscription and the conflict info
> > insertion is performed by the subscription owner, we would end up
> > having the same security issue that was addressed by the run_as_owner
> > subscription option.
> >
>
> Yeah, I don't think we want to open that door. For user created
> tables, we should perform actions with table_owner's privilege. In
> such a case, if one wants to create a subscription with run_as_owner
> option, she should give DML operation permissions to the subscription
> owner. OTOH, if we create this table internally (via subscription
> owner) then irrespective of run_as_owner, we will always insert as
> subscription_owner.
Agreed.
>
> AFAIR, one open point for internally created tables is whether we
> should skip changes to conflict_history table while replicating
> changes? The table will be considered under for ALL TABLES
> publications, if defined? Ideally, these should behave as catalog
> tables, so one option is to mark them as 'user_catalog_table', or the
> other option is we have some hard-code checks during replication. The
> first option has the advantage that it won't write additional WAL for
> these tables which is otherwise required under wal_level=logical. What
> other options do we have?
I think conflict history information is subscriber local information
so doesn't have to be replicated to another subscriber. Also it could
be problematic in cross-major-version replication cases if we break
the compatibility of history table definition. I would expect that the
history table works as a catalog table in terms of logical
decoding/replication. It would probably make sense to reuse the
user_catalog_table option for that purpose. If we have a history table
for each subscription that wants to record the conflict history (I
believe so), it would be hard to go with the second option (having
hard-code checks).
Regards,
--
Masahiko Sawada
Amazon Web Services: https://aws.amazon.com
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-09-24 10:30 Amit Kapila <amit.kapila16@gmail.com>
parent: Masahiko Sawada <sawada.mshk@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: Amit Kapila @ 2025-09-24 10:30 UTC (permalink / raw)
To: Masahiko Sawada <sawada.mshk@gmail.com>; +Cc: Dilip Kumar <dilipbalaut@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, Sep 23, 2025 at 11:29 PM Masahiko Sawada <sawada.mshk@gmail.com> wrote:
>
> On Sat, Sep 20, 2025 at 4:59 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> >
> > AFAIR, one open point for internally created tables is whether we
> > should skip changes to conflict_history table while replicating
> > changes? The table will be considered under for ALL TABLES
> > publications, if defined? Ideally, these should behave as catalog
> > tables, so one option is to mark them as 'user_catalog_table', or the
> > other option is we have some hard-code checks during replication. The
> > first option has the advantage that it won't write additional WAL for
> > these tables which is otherwise required under wal_level=logical. What
> > other options do we have?
>
> I think conflict history information is subscriber local information
> so doesn't have to be replicated to another subscriber. Also it could
> be problematic in cross-major-version replication cases if we break
> the compatibility of history table definition.
>
Right, this is another reason not to replicate it.
> I would expect that the
> history table works as a catalog table in terms of logical
> decoding/replication. It would probably make sense to reuse the
> user_catalog_table option for that purpose. If we have a history table
> for each subscription that wants to record the conflict history (I
> believe so), it would be hard to go with the second option (having
> hard-code checks).
>
Agreed. Let's wait and see what Dilip or others have to say on this.
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-09-24 11:35 Dilip Kumar <dilipbalaut@gmail.com>
parent: Masahiko Sawada <sawada.mshk@gmail.com>
1 sibling, 0 replies; 912+ messages in thread
From: Dilip Kumar @ 2025-09-24 11:35 UTC (permalink / raw)
To: Masahiko Sawada <sawada.mshk@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, Sep 23, 2025 at 11:29 PM Masahiko Sawada <sawada.mshk@gmail.com> wrote:
>
> On Sat, Sep 20, 2025 at 4:59 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
> >
> > On Thu, Sep 18, 2025 at 11:46 PM Masahiko Sawada <sawada.mshk@gmail.com> wrote:
> > >
> > > On Thu, Sep 18, 2025 at 1:33 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > > >
> > > > If we compare conflict_history_table with the slot that gets created
> > > > with subscription, one can say the same thing about slots. Users can
> > > > drop the slots and whole replication will stop. I think this table
> > > > will be created with the same privileges as the owner of a
> > > > subscription which can be either a superuser or a user with the
> > > > privileges of the pg_create_subscription role, so we can rely on such
> > > > users.
> > >
> > > We might want to consider which role inserts the conflict info into
> > > the history table. For example, if any table created by a user can be
> > > used as the history table for a subscription and the conflict info
> > > insertion is performed by the subscription owner, we would end up
> > > having the same security issue that was addressed by the run_as_owner
> > > subscription option.
> > >
> >
> > Yeah, I don't think we want to open that door. For user created
> > tables, we should perform actions with table_owner's privilege. In
> > such a case, if one wants to create a subscription with run_as_owner
> > option, she should give DML operation permissions to the subscription
> > owner. OTOH, if we create this table internally (via subscription
> > owner) then irrespective of run_as_owner, we will always insert as
> > subscription_owner.
>
> Agreed.
Yeah that makes sense to me as well.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-09-24 11:40 Dilip Kumar <dilipbalaut@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-09-24 11:40 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Wed, Sep 24, 2025 at 4:00 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Tue, Sep 23, 2025 at 11:29 PM Masahiko Sawada <sawada.mshk@gmail.com> wrote:
> >
> > On Sat, Sep 20, 2025 at 4:59 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
> >
> > >
> > > AFAIR, one open point for internally created tables is whether we
> > > should skip changes to conflict_history table while replicating
> > > changes? The table will be considered under for ALL TABLES
> > > publications, if defined? Ideally, these should behave as catalog
> > > tables, so one option is to mark them as 'user_catalog_table', or the
> > > other option is we have some hard-code checks during replication. The
> > > first option has the advantage that it won't write additional WAL for
> > > these tables which is otherwise required under wal_level=logical. What
> > > other options do we have?
> >
> > I think conflict history information is subscriber local information
> > so doesn't have to be replicated to another subscriber. Also it could
> > be problematic in cross-major-version replication cases if we break
> > the compatibility of history table definition.
> >
>
> Right, this is another reason not to replicate it.
>
> > I would expect that the
> > history table works as a catalog table in terms of logical
> > decoding/replication. It would probably make sense to reuse the
> > user_catalog_table option for that purpose. If we have a history table
> > for each subscription that wants to record the conflict history (I
> > believe so), it would be hard to go with the second option (having
> > hard-code checks).
> >
>
> Agreed. Let's wait and see what Dilip or others have to say on this.
Yeah I think this makes sense to create as 'user_catalog_table' tables
when we internally create them. However, IMHO when a user provides
its own table, I believe we should not enforce the restriction for
that table to be created as a 'user_catalog_table' table, or do you
think we should enforce that property?
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-09-24 18:35 Masahiko Sawada <sawada.mshk@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 0 replies; 912+ messages in thread
From: Masahiko Sawada @ 2025-09-24 18:35 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Wed, Sep 24, 2025 at 4:40 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Wed, Sep 24, 2025 at 4:00 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> >
> > On Tue, Sep 23, 2025 at 11:29 PM Masahiko Sawada <sawada.mshk@gmail.com> wrote:
> > >
> > > On Sat, Sep 20, 2025 at 4:59 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > >
> > > >
> > > > AFAIR, one open point for internally created tables is whether we
> > > > should skip changes to conflict_history table while replicating
> > > > changes? The table will be considered under for ALL TABLES
> > > > publications, if defined? Ideally, these should behave as catalog
> > > > tables, so one option is to mark them as 'user_catalog_table', or the
> > > > other option is we have some hard-code checks during replication. The
> > > > first option has the advantage that it won't write additional WAL for
> > > > these tables which is otherwise required under wal_level=logical. What
> > > > other options do we have?
> > >
> > > I think conflict history information is subscriber local information
> > > so doesn't have to be replicated to another subscriber. Also it could
> > > be problematic in cross-major-version replication cases if we break
> > > the compatibility of history table definition.
> > >
> >
> > Right, this is another reason not to replicate it.
> >
> > > I would expect that the
> > > history table works as a catalog table in terms of logical
> > > decoding/replication. It would probably make sense to reuse the
> > > user_catalog_table option for that purpose. If we have a history table
> > > for each subscription that wants to record the conflict history (I
> > > believe so), it would be hard to go with the second option (having
> > > hard-code checks).
> > >
> >
> > Agreed. Let's wait and see what Dilip or others have to say on this.
>
> Yeah I think this makes sense to create as 'user_catalog_table' tables
> when we internally create them. However, IMHO when a user provides
> its own table, I believe we should not enforce the restriction for
> that table to be created as a 'user_catalog_table' table, or do you
> think we should enforce that property?
I find that's a user's responsibility, so I would not enforce that
property for user-provided-tables.
BTW what is the main use case for supporting the use of user-provided
tables for the history table? I think we basically don't want the
history table to be updated by any other processes than apply workers,
so it would make more sense that such a table is created internally
and tied to the subscription. I'm less convinced that it has enough
upside to warrant the complexity.
Regards,
--
Masahiko Sawada
Amazon Web Services: https://aws.amazon.com
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-09-25 05:39 Dilip Kumar <dilipbalaut@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-09-25 05:39 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Sat, Sep 20, 2025 at 5:29 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Thu, Sep 18, 2025 at 11:46 PM Masahiko Sawada <sawada.mshk@gmail.com> wrote:
> >
> > On Thu, Sep 18, 2025 at 1:33 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > >
> > > If we compare conflict_history_table with the slot that gets created
> > > with subscription, one can say the same thing about slots. Users can
> > > drop the slots and whole replication will stop. I think this table
> > > will be created with the same privileges as the owner of a
> > > subscription which can be either a superuser or a user with the
> > > privileges of the pg_create_subscription role, so we can rely on such
> > > users.
> >
> > We might want to consider which role inserts the conflict info into
> > the history table. For example, if any table created by a user can be
> > used as the history table for a subscription and the conflict info
> > insertion is performed by the subscription owner, we would end up
> > having the same security issue that was addressed by the run_as_owner
> > subscription option.
> >
>
> Yeah, I don't think we want to open that door. For user created
> tables, we should perform actions with table_owner's privilege. In
> such a case, if one wants to create a subscription with run_as_owner
> option, she should give DML operation permissions to the subscription
> owner. OTOH, if we create this table internally (via subscription
> owner) then irrespective of run_as_owner, we will always insert as
> subscription_owner.
>
> AFAIR, one open point for internally created tables is whether we
> should skip changes to conflict_history table while replicating
> changes? The table will be considered under for ALL TABLES
> publications, if defined? Ideally, these should behave as catalog
> tables, so one option is to mark them as 'user_catalog_table', or the
> other option is we have some hard-code checks during replication. The
> first option has the advantage that it won't write additional WAL for
> these tables which is otherwise required under wal_level=logical. What
> other options do we have?
I was doing more analysis and testing for 'use_catalog_table', so what
I found is when a table is marked as 'use_catalog_table', it will log
extra information i.e. CID[1] so that these tables can be used for
scanning as well during decoding like catalog tables using historical
snapshot. And I have checked the code and tested as well
'use_catalog_table' does get streamed with ALL TABLE options. Am I
missing something or are we thinking of changing the behavior of
use_catalog_table so that they do not get decoded, but I think that
will change the existing behaviour so might not be a good option, yet
another idea is to invent some other option for which purpose called
'conflict_history_purpose' but maybe that doesn't justify the purpose
of the new option IMHO.
[1]
/*
* For logical decode we need combo CIDs to properly decode the
* catalog
*/
if (RelationIsAccessibleInLogicalDecoding(relation))
log_heap_new_cid(relation, &tp);
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-09-25 06:23 Dilip Kumar <dilipbalaut@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-09-25 06:23 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, Sep 25, 2025 at 11:09 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Sat, Sep 20, 2025 at 5:29 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> >
> > On Thu, Sep 18, 2025 at 11:46 PM Masahiko Sawada <sawada.mshk@gmail.com> wrote:
> > >
> > > On Thu, Sep 18, 2025 at 1:33 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > > >
> > > > If we compare conflict_history_table with the slot that gets created
> > > > with subscription, one can say the same thing about slots. Users can
> > > > drop the slots and whole replication will stop. I think this table
> > > > will be created with the same privileges as the owner of a
> > > > subscription which can be either a superuser or a user with the
> > > > privileges of the pg_create_subscription role, so we can rely on such
> > > > users.
> > >
> > > We might want to consider which role inserts the conflict info into
> > > the history table. For example, if any table created by a user can be
> > > used as the history table for a subscription and the conflict info
> > > insertion is performed by the subscription owner, we would end up
> > > having the same security issue that was addressed by the run_as_owner
> > > subscription option.
> > >
> >
> > Yeah, I don't think we want to open that door. For user created
> > tables, we should perform actions with table_owner's privilege. In
> > such a case, if one wants to create a subscription with run_as_owner
> > option, she should give DML operation permissions to the subscription
> > owner. OTOH, if we create this table internally (via subscription
> > owner) then irrespective of run_as_owner, we will always insert as
> > subscription_owner.
> >
> > AFAIR, one open point for internally created tables is whether we
> > should skip changes to conflict_history table while replicating
> > changes? The table will be considered under for ALL TABLES
> > publications, if defined? Ideally, these should behave as catalog
> > tables, so one option is to mark them as 'user_catalog_table', or the
> > other option is we have some hard-code checks during replication. The
> > first option has the advantage that it won't write additional WAL for
> > these tables which is otherwise required under wal_level=logical. What
> > other options do we have?
>
> I was doing more analysis and testing for 'use_catalog_table', so what
> I found is when a table is marked as 'use_catalog_table', it will log
> extra information i.e. CID[1] so that these tables can be used for
> scanning as well during decoding like catalog tables using historical
> snapshot. And I have checked the code and tested as well
> 'use_catalog_table' does get streamed with ALL TABLE options. Am I
> missing something or are we thinking of changing the behavior of
> use_catalog_table so that they do not get decoded, but I think that
> will change the existing behaviour so might not be a good option, yet
> another idea is to invent some other option for which purpose called
> 'conflict_history_purpose' but maybe that doesn't justify the purpose
> of the new option IMHO.
>
> [1]
> /*
> * For logical decode we need combo CIDs to properly decode the
> * catalog
> */
> if (RelationIsAccessibleInLogicalDecoding(relation))
> log_heap_new_cid(relation, &tp);
>
Meanwhile I am also exploring the option where we can just CREATE TYPE
in initialize_data_directory() during initdb, basically we will create
this type in template1 so that it will be available in all the
databases, and that would simplify the table creation whether we
create internally or we allow user to create it. And while checking
is_publishable_class we can check the type and avoid publishing those
tables.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-09-25 10:49 Dilip Kumar <dilipbalaut@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-09-25 10:49 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, Sep 25, 2025 at 11:53 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> > [1]
> > /*
> > * For logical decode we need combo CIDs to properly decode the
> > * catalog
> > */
> > if (RelationIsAccessibleInLogicalDecoding(relation))
> > log_heap_new_cid(relation, &tp);
> >
>
> Meanwhile I am also exploring the option where we can just CREATE TYPE
> in initialize_data_directory() during initdb, basically we will create
> this type in template1 so that it will be available in all the
> databases, and that would simplify the table creation whether we
> create internally or we allow user to create it. And while checking
> is_publishable_class we can check the type and avoid publishing those
> tables.
>
Based on my off list discussion with Amit, one option could be to set
HEAP_INSERT_NO_LOGICAL option while inserting tuple into conflict
history table, for that we can not use SPI interface to insert instead
we will have to directly call the heap_insert() to add this option.
Since we do not want to create any trigger etc on this table, direct
insert should be fine, but if we plan to create this table as
partitioned table in future then direct heap insert might not work.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-09-26 11:12 Dilip Kumar <dilipbalaut@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 2 replies; 912+ messages in thread
From: Dilip Kumar @ 2025-09-26 11:12 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, Sep 25, 2025 at 4:19 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Thu, Sep 25, 2025 at 11:53 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > > [1]
> > > /*
> > > * For logical decode we need combo CIDs to properly decode the
> > > * catalog
> > > */
> > > if (RelationIsAccessibleInLogicalDecoding(relation))
> > > log_heap_new_cid(relation, &tp);
> > >
> >
> > Meanwhile I am also exploring the option where we can just CREATE TYPE
> > in initialize_data_directory() during initdb, basically we will create
> > this type in template1 so that it will be available in all the
> > databases, and that would simplify the table creation whether we
> > create internally or we allow user to create it. And while checking
> > is_publishable_class we can check the type and avoid publishing those
> > tables.
> >
>
> Based on my off list discussion with Amit, one option could be to set
> HEAP_INSERT_NO_LOGICAL option while inserting tuple into conflict
> history table, for that we can not use SPI interface to insert instead
> we will have to directly call the heap_insert() to add this option.
> Since we do not want to create any trigger etc on this table, direct
> insert should be fine, but if we plan to create this table as
> partitioned table in future then direct heap insert might not work.
Upon further reflection, I realized that while this approach avoids
streaming inserts to the conflict log history table, it still requires
that table to exist on the subscriber node upon subscription creation,
which isn't ideal.
We have two main options to address this:
Option1:
When calling pg_get_publication_tables(), if the 'alltables' option is
used, we can scan all subscriptions and explicitly ignore (filter out)
all conflict history tables. This will not be very costly as this
will scan the subscriber when pg_get_publication_tables() is called,
which is only called during create subscription/alter subscription on
the remote node.
Option2:
Alternatively, we could introduce a table creation option, like a
'non-publishable' flag, to prevent a table from being streamed
entirely. I believe this would be a valuable, independent feature for
users who want to create certain tables without including them in
logical replication.
I prefer option2, as I feel this can add value independent of this patch.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-09-27 15:23 Amit Kapila <amit.kapila16@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: Amit Kapila @ 2025-09-27 15:23 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Fri, Sep 26, 2025 at 4:42 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Thu, Sep 25, 2025 at 4:19 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Thu, Sep 25, 2025 at 11:53 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > > [1]
> > > > /*
> > > > * For logical decode we need combo CIDs to properly decode the
> > > > * catalog
> > > > */
> > > > if (RelationIsAccessibleInLogicalDecoding(relation))
> > > > log_heap_new_cid(relation, &tp);
> > > >
> > >
> > > Meanwhile I am also exploring the option where we can just CREATE TYPE
> > > in initialize_data_directory() during initdb, basically we will create
> > > this type in template1 so that it will be available in all the
> > > databases, and that would simplify the table creation whether we
> > > create internally or we allow user to create it. And while checking
> > > is_publishable_class we can check the type and avoid publishing those
> > > tables.
> > >
> >
> > Based on my off list discussion with Amit, one option could be to set
> > HEAP_INSERT_NO_LOGICAL option while inserting tuple into conflict
> > history table, for that we can not use SPI interface to insert instead
> > we will have to directly call the heap_insert() to add this option.
> > Since we do not want to create any trigger etc on this table, direct
> > insert should be fine, but if we plan to create this table as
> > partitioned table in future then direct heap insert might not work.
>
> Upon further reflection, I realized that while this approach avoids
> streaming inserts to the conflict log history table, it still requires
> that table to exist on the subscriber node upon subscription creation,
> which isn't ideal.
>
I am not able to understand what exact problem you are seeing here. I
was thinking that during the CREATE SUBSCRIPTION command, a new table
with user provided name will be created similar to how we create a
slot. The difference would be that we create a slot on the
remote/publisher node but this table will be created locally.
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-09-27 15:54 Dilip Kumar <dilipbalaut@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-09-27 15:54 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Sat, Sep 27, 2025 at 8:53 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> I am not able to understand what exact problem you are seeing here. I
> was thinking that during the CREATE SUBSCRIPTION command, a new table
> with user provided name will be created similar to how we create a
> slot. The difference would be that we create a slot on the
> remote/publisher node but this table will be created locally.
>
That's not an issue, the problem here we are discussing is the
conflict history table which is created on the subscriber node should
not be published when this node subscription node create another
publisher with ALL TABLE option. So we found a option for inserting
into this table with HEAP_INSERT_NO_LOGICAL flag so that those insert
will not be decoded, but what about another not subscribing from this
publisher, they should have this table because when ALL TABLES are
published subscriber node expect all user table to present there even
if its changes are not published. Consider below example
Node1:
CREATE PUBLICATION pub_node1..
Node2:
CREATE SUBSCRIPTION sub.. PUBLICATION pub_node1
WITH(conflict_history_table='my_conflict_table');
CREATE PUBLICATION pub_node2 FOR ALL TABLE;
Node3:
CREATE SUBSCRIPTION sub1.. PUBLICATION pub_node2; --this will expect
'my_conflict_table' to exist here because when it will call
pg_get_publication_tables() from Node2 it will also get the
'my_conflict_table' along with other user tables.
And as a solution I wanted to avoid this table to be avoided when
pg_get_publication_tables() is being called.
Option1: We can see if table name is listed as conflict history table
in any of the subscribers on Node2 we will ignore this.
Option2: Provide a new table option to mark table as non publishable
table when ALL TABLE option is provided, I think this option can be
useful independently as well.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-09-27 21:13 Amit Kapila <amit.kapila16@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Amit Kapila @ 2025-09-27 21:13 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Sat, Sep 27, 2025 at 9:24 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Sat, Sep 27, 2025 at 8:53 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> >
> > I am not able to understand what exact problem you are seeing here. I
> > was thinking that during the CREATE SUBSCRIPTION command, a new table
> > with user provided name will be created similar to how we create a
> > slot. The difference would be that we create a slot on the
> > remote/publisher node but this table will be created locally.
> >
> That's not an issue, the problem here we are discussing is the
> conflict history table which is created on the subscriber node should
> not be published when this node subscription node create another
> publisher with ALL TABLE option. So we found a option for inserting
> into this table with HEAP_INSERT_NO_LOGICAL flag so that those insert
> will not be decoded, but what about another not subscribing from this
> publisher, they should have this table because when ALL TABLES are
> published subscriber node expect all user table to present there even
> if its changes are not published. Consider below example
>
> Node1:
> CREATE PUBLICATION pub_node1..
>
> Node2:
> CREATE SUBSCRIPTION sub.. PUBLICATION pub_node1
> WITH(conflict_history_table='my_conflict_table');
> CREATE PUBLICATION pub_node2 FOR ALL TABLE;
>
> Node3:
> CREATE SUBSCRIPTION sub1.. PUBLICATION pub_node2; --this will expect
> 'my_conflict_table' to exist here because when it will call
> pg_get_publication_tables() from Node2 it will also get the
> 'my_conflict_table' along with other user tables.
>
> And as a solution I wanted to avoid this table to be avoided when
> pg_get_publication_tables() is being called.
> Option1: We can see if table name is listed as conflict history table
> in any of the subscribers on Node2 we will ignore this.
> Option2: Provide a new table option to mark table as non publishable
> table when ALL TABLE option is provided, I think this option can be
> useful independently as well.
>
I agree that option-2 is useful and IIUC, we are already working on
something similar in thread [1]. However, it is better to use option-1
here because we are using non-user specified mechanism to skip changes
during replication, so following the same during other times is
preferable. Once we have that other feature [1], we can probably
optimize this code to use it without taking input from the user. The
other reason of not going with the option-2 in the way you are
proposing is that it doesn't seem like a good idea to have multiple
ways to specify skipping tables from publishing. I find the approach
being discussed in thread [1] a generic and better than a new
table-level option.
[1] - https://www.postgresql.org/message-id/CANhcyEVt2CBnG7MOktaPPV4rYapHR-VHe5%3DqoziTZh1L9SVc6w%40mail.g...
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-09-28 11:45 Dilip Kumar <dilipbalaut@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-09-28 11:45 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Sun, Sep 28, 2025 at 2:43 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> I agree that option-2 is useful and IIUC, we are already working on
> something similar in thread [1]. However, it is better to use option-1
> here because we are using non-user specified mechanism to skip changes
> during replication, so following the same during other times is
> preferable. Once we have that other feature [1], we can probably
> optimize this code to use it without taking input from the user. The
> other reason of not going with the option-2 in the way you are
> proposing is that it doesn't seem like a good idea to have multiple
> ways to specify skipping tables from publishing. I find the approach
> being discussed in thread [1] a generic and better than a new
> table-level option.
>
> [1] - https://www.postgresql.org/message-id/CANhcyEVt2CBnG7MOktaPPV4rYapHR-VHe5%3DqoziTZh1L9SVc6w%40mail.g...
I understand the current discussion revolves around using an EXCEPT
clause (for tables/schemas/columns) during publication creation. But
what we want is to mark some table which will be excluded permanently
from publication, because we can not expect users to explicitly
exclude them while creating publication.
So, I propose we add a "non-publishable" property to tables
themselves. This is a more valuable option for users who are certain
that certain tables should never be replicated.
By marking a table as non-publishable, we save users the effort of
repeatedly listing it in the EXCEPT option for every new publication.
Both methods have merit, but the proposed table property addresses the
need for a permanent, system-wide exclusion.
See below test with a quick hack, what I am referring to.
postgres[2730657]=# CREATE TABLE test(a int) WITH
(NON_PUBLISHABLE_TABLE = true);
CREATE TABLE
postgres[2730657]=# CREATE PUBLICATION pub FOR ALL TABLES ;
CREATE PUBLICATION
postgres[2730657]=# select pg_get_publication_tables('pub');
pg_get_publication_tables
---------------------------
(0 rows)
But I agree this is an additional table option which might need
consensus, so meanwhile we can proceed with option2, I will prepare
patches with option-2 and as a add on patch I will propose option-1.
And this option-1 patch can be discussed in a separate thread as well.
--
Regards,
Dilip Kumar
Google
Attachments:
[application/octet-stream] v1-0001-non-publishable-rel.patch (5.7K, ../../CAFiTN-uRJ=YaRY4sVn62JYU0RA5cWBR_YB6Tio63x+yYW-3A4Q@mail.gmail.com/2-v1-0001-non-publishable-rel.patch)
download | inline diff:
From b3f9e4435125be9939b9b65f5b823b51edb3f18d Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Fri, 26 Sep 2025 17:14:50 +0530
Subject: [PATCH v1] non publishable rel
---
src/backend/access/common/reloptions.c | 13 ++++++++-
src/backend/catalog/pg_publication.c | 38 ++++++++++++++++++++------
src/bin/psql/tab-complete.in.c | 1 +
src/include/utils/rel.h | 11 ++++++++
4 files changed, 54 insertions(+), 9 deletions(-)
diff --git a/src/backend/access/common/reloptions.c b/src/backend/access/common/reloptions.c
index 35150bf237b..6aa8acd83ea 100644
--- a/src/backend/access/common/reloptions.c
+++ b/src/backend/access/common/reloptions.c
@@ -166,6 +166,15 @@ static relopt_bool boolRelOpts[] =
},
true
},
+ {
+ {
+ "non_publishable_table",
+ "Mark table as non publishable so that this will can not be included in publication",
+ RELOPT_KIND_HEAP,
+ AccessExclusiveLock
+ },
+ true
+ },
/* list terminator */
{{NULL}}
};
@@ -1915,7 +1924,9 @@ default_reloptions(Datum reloptions, bool validate, relopt_kind kind)
{"vacuum_truncate", RELOPT_TYPE_BOOL,
offsetof(StdRdOptions, vacuum_truncate), offsetof(StdRdOptions, vacuum_truncate_set)},
{"vacuum_max_eager_freeze_failure_rate", RELOPT_TYPE_REAL,
- offsetof(StdRdOptions, vacuum_max_eager_freeze_failure_rate)}
+ offsetof(StdRdOptions, vacuum_max_eager_freeze_failure_rate)},
+ {"non_publishable_table", RELOPT_TYPE_BOOL,
+ offsetof(StdRdOptions, non_publishable_table)}
};
return (bytea *) build_reloptions(reloptions, validate, kind,
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index b911efcf9cb..27f8b685817 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -146,7 +146,8 @@ is_publishable_class(Oid relid, Form_pg_class reltuple)
bool
is_publishable_relation(Relation rel)
{
- return is_publishable_class(RelationGetRelid(rel), rel->rd_rel);
+ return is_publishable_class(RelationGetRelid(rel), rel->rd_rel) &&
+ !RelationIsNotPublishable(rel);
}
/*
@@ -162,12 +163,16 @@ pg_relation_is_publishable(PG_FUNCTION_ARGS)
Oid relid = PG_GETARG_OID(0);
HeapTuple tuple;
bool result;
+ Relation rel;
- tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
- if (!HeapTupleIsValid(tuple))
+ rel = try_table_open(relid, AccessShareLock);
+ if (rel == NULL)
PG_RETURN_NULL();
- result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple));
- ReleaseSysCache(tuple);
+
+ result = is_publishable_relation(rel);
+
+ table_close(rel, AccessShareLock);
+
PG_RETURN_BOOL(result);
}
@@ -882,10 +887,15 @@ GetAllTablesPublicationRelations(bool pubviaroot)
{
Form_pg_class relForm = (Form_pg_class) GETSTRUCT(tuple);
Oid relid = relForm->oid;
+ Relation rel;
+
+ rel = table_open(relid, AccessShareLock);
- if (is_publishable_class(relid, relForm) &&
+ if (is_publishable_relation(rel) &&
!(relForm->relispartition && pubviaroot))
result = lappend_oid(result, relid);
+
+ table_close(rel, AccessShareLock);
}
table_endscan(scan);
@@ -903,10 +913,15 @@ GetAllTablesPublicationRelations(bool pubviaroot)
{
Form_pg_class relForm = (Form_pg_class) GETSTRUCT(tuple);
Oid relid = relForm->oid;
+ Relation rel;
- if (is_publishable_class(relid, relForm) &&
+ rel = table_open(relid, AccessShareLock);
+
+ if (is_publishable_relation(rel) &&
!relForm->relispartition)
result = lappend_oid(result, relid);
+
+ table_close(rel, AccessShareLock);
}
table_endscan(scan);
@@ -1010,9 +1025,16 @@ GetSchemaPublicationRelations(Oid schemaid, PublicationPartOpt pub_partopt)
Form_pg_class relForm = (Form_pg_class) GETSTRUCT(tuple);
Oid relid = relForm->oid;
char relkind;
+ Relation rel;
+
+ rel = table_open(relid, AccessShareLock);
- if (!is_publishable_class(relid, relForm))
+ if (!is_publishable_relation(rel))
+ {
+ table_close(rel, AccessShareLock);
continue;
+ }
+ table_close(rel, AccessShareLock);
relkind = get_rel_relkind(relid);
if (relkind == RELKIND_RELATION)
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 6b20a4404b2..74d4c164f62 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -1411,6 +1411,7 @@ static const char *const table_storage_parameters[] = {
"autovacuum_vacuum_threshold",
"fillfactor",
"log_autovacuum_min_duration",
+ "non_publishable_table",
"parallel_workers",
"toast.autovacuum_enabled",
"toast.autovacuum_freeze_max_age",
diff --git a/src/include/utils/rel.h b/src/include/utils/rel.h
index 21990436373..033c365f469 100644
--- a/src/include/utils/rel.h
+++ b/src/include/utils/rel.h
@@ -348,6 +348,7 @@ typedef struct StdRdOptions
StdRdOptIndexCleanup vacuum_index_cleanup; /* controls index vacuuming */
bool vacuum_truncate; /* enables vacuum to truncate a relation */
bool vacuum_truncate_set; /* whether vacuum_truncate is set */
+ bool non_publishable_table; /* table will not be published */
/*
* Fraction of pages in a relation that vacuum can eagerly scan and fail
@@ -400,6 +401,16 @@ typedef struct StdRdOptions
(relation)->rd_rel->relkind == RELKIND_MATVIEW) ? \
((StdRdOptions *) (relation)->rd_options)->user_catalog_table : false)
+/*
+ * RelationIsNonPublishable
+ * Returns whether the relation can be added in publication or not.
+ */
+#define RelationIsNotPublishable(relation) \
+ ((relation)->rd_options && \
+ ((relation)->rd_rel->relkind == RELKIND_RELATION || \
+ (relation)->rd_rel->relkind == RELKIND_MATVIEW) ? \
+ ((StdRdOptions *) (relation)->rd_options)->non_publishable_table : false)
+
/*
* RelationGetParallelWorkers
* Returns the relation's parallel_workers reloption setting.
--
2.49.0
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-09-29 09:57 Dilip Kumar <dilipbalaut@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-09-29 09:57 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Sun, Sep 28, 2025 at 5:15 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Sun, Sep 28, 2025 at 2:43 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
> >
>
> > I agree that option-2 is useful and IIUC, we are already working on
> > something similar in thread [1]. However, it is better to use option-1
> > here because we are using non-user specified mechanism to skip changes
> > during replication, so following the same during other times is
> > preferable. Once we have that other feature [1], we can probably
> > optimize this code to use it without taking input from the user. The
> > other reason of not going with the option-2 in the way you are
> > proposing is that it doesn't seem like a good idea to have multiple
> > ways to specify skipping tables from publishing. I find the approach
> > being discussed in thread [1] a generic and better than a new
> > table-level option.
> >
> > [1] - https://www.postgresql.org/message-id/CANhcyEVt2CBnG7MOktaPPV4rYapHR-VHe5%3DqoziTZh1L9SVc6w%40mail.g...
>
> I understand the current discussion revolves around using an EXCEPT
> clause (for tables/schemas/columns) during publication creation. But
> what we want is to mark some table which will be excluded permanently
> from publication, because we can not expect users to explicitly
> exclude them while creating publication.
>
> So, I propose we add a "non-publishable" property to tables
> themselves. This is a more valuable option for users who are certain
> that certain tables should never be replicated.
>
> By marking a table as non-publishable, we save users the effort of
> repeatedly listing it in the EXCEPT option for every new publication.
> Both methods have merit, but the proposed table property addresses the
> need for a permanent, system-wide exclusion.
>
> See below test with a quick hack, what I am referring to.
>
> postgres[2730657]=# CREATE TABLE test(a int) WITH
> (NON_PUBLISHABLE_TABLE = true);
> CREATE TABLE
> postgres[2730657]=# CREATE PUBLICATION pub FOR ALL TABLES ;
> CREATE PUBLICATION
> postgres[2730657]=# select pg_get_publication_tables('pub');
> pg_get_publication_tables
> ---------------------------
> (0 rows)
>
>
> But I agree this is an additional table option which might need
> consensus, so meanwhile we can proceed with option2, I will prepare
> patches with option-2 and as a add on patch I will propose option-1.
> And this option-1 patch can be discussed in a separate thread as well.
So here is the patch set using option-2, with this when alltable
option is used and we get pg_get_publication_tables(), this will check
the relid against the conflict history tables in the subscribers and
those tables will not be added to the list. I will start a separate
thread for proposing the patch I sent in previous email.
--
Regards,
Dilip Kumar
Google
Attachments:
[application/octet-stream] v2-0001-Add-configurable-conflict-log-history-table-for-L.patch (20.8K, ../../CAFiTN-uSs5kiJ6cMq9VZ03+wwOxGpsh707XX+1sWNO-9NcyXrA@mail.gmail.com/2-v2-0001-Add-configurable-conflict-log-history-table-for-L.patch)
download | inline diff:
From 052c9df805da4be6a0afcd28690e004a20e01221 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Sun, 14 Sep 2025 08:38:22 +0530
Subject: [PATCH v2 1/2] Add configurable conflict log history table for
Logical Replication
This patch adds a feature to provide a structured, queryable record of all
logical replication conflicts. The current approach of logging conflicts as
plain text in the server logs makes it difficult to query, analyze, and
use for external monitoring and automation.
This patch addresses these limitations by introducing a configurable
conflict_log_table option in the CREATE SUBSCRIPTION command. Key design
decisions include:
User-Defined Table: The conflict log is stored in a user-managed table
rather than a system catalog.
Structured Data: Conflict details, including the original and remote tuples,
are stored in JSON columns, providing a flexible format to accommodate different
table schemas.
Comprehensive Information: The log table captures essential attributes such as
local and remote transaction IDs, LSNs, commit timestamps, and conflict type,
providing a complete record for post-mortem analysis.
This feature will make logical replication conflicts easier to monitor and manage,
significantly improving the overall resilience and operability of replication setups.
---
src/backend/commands/subscriptioncmds.c | 127 ++++++++++++++-
src/backend/replication/logical/conflict.c | 170 +++++++++++++++++++++
src/backend/replication/logical/worker.c | 10 +-
src/backend/utils/cache/lsyscache.c | 37 +++++
src/include/catalog/pg_subscription.h | 5 +
src/include/replication/conflict.h | 2 +
src/include/replication/worker_internal.h | 4 +
src/include/utils/lsyscache.h | 1 +
8 files changed, 352 insertions(+), 4 deletions(-)
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index 750d262fcca..dbaeed4b0b1 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -34,6 +34,7 @@
#include "commands/event_trigger.h"
#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "executor/spi.h"
#include "miscadmin.h"
#include "nodes/makefuncs.h"
#include "pgstat.h"
@@ -47,10 +48,12 @@
#include "storage/lmgr.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/guc.h"
#include "utils/lsyscache.h"
#include "utils/memutils.h"
#include "utils/pg_lsn.h"
+#include "utils/regproc.h"
#include "utils/syscache.h"
/*
@@ -75,6 +78,7 @@
#define SUBOPT_MAX_RETENTION_DURATION 0x00008000
#define SUBOPT_LSN 0x00010000
#define SUBOPT_ORIGIN 0x00020000
+#define SUBOPT_CONFLICT_TABLE 0x00030000
/* check if the 'val' has 'bits' set */
#define IsSet(val, bits) (((val) & (bits)) == (bits))
@@ -103,6 +107,7 @@ typedef struct SubOpts
bool retaindeadtuples;
int32 maxretention;
char *origin;
+ char *conflicttable;
XLogRecPtr lsn;
} SubOpts;
@@ -118,7 +123,7 @@ static List *merge_publications(List *oldpublist, List *newpublist, bool addpub,
static void ReportSlotConnectionError(List *rstates, Oid subid, char *slotname, char *err);
static void CheckAlterSubOption(Subscription *sub, const char *option,
bool slot_needs_update, bool isTopLevel);
-
+static void CreateConflictHistoryTable(Oid namespaceId, char *conflictrel);
/*
* Common option parsing function for CREATE and ALTER SUBSCRIPTION commands.
@@ -174,6 +179,8 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->maxretention = 0;
if (IsSet(supported_opts, SUBOPT_ORIGIN))
opts->origin = pstrdup(LOGICALREP_ORIGIN_ANY);
+ if (IsSet(supported_opts, SUBOPT_CONFLICT_TABLE))
+ opts->conflicttable = NULL;
/* Parse options */
foreach(lc, stmt_options)
@@ -385,6 +392,15 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->specified_opts |= SUBOPT_LSN;
opts->lsn = lsn;
}
+ else if (IsSet(supported_opts, SUBOPT_CONFLICT_TABLE) &&
+ strcmp(defel->defname, "conflict_log_table") == 0)
+ {
+ if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_TABLE))
+ errorConflictingDefElem(defel, pstate);
+
+ opts->specified_opts |= SUBOPT_CONFLICT_TABLE;
+ opts->conflicttable = defGetString(defel);
+ }
else
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
@@ -560,6 +576,65 @@ publicationListToArray(List *publist)
return PointerGetDatum(arr);
}
+/*
+ * CreateConflictHistoryTable: Create conflict log history table.
+ *
+ * The subscription creator becomes the owner of this table and has all
+ * privileges on it.
+ */
+static void
+CreateConflictHistoryTable(Oid namespaceId, char *conflictrel)
+{
+ StringInfoData querybuf;
+
+ /*
+ * Check if table with same name already present, if so report and error
+ * as currently we do not support user created table as conflict history
+ * table.
+ */
+ if (OidIsValid(get_relname_relid(conflictrel, namespaceId)))
+ ereport(ERROR,
+ (errcode(ERRCODE_DUPLICATE_OBJECT),
+ errmsg("table \"%s.%s\" already exists",
+ get_namespace_name(namespaceId), conflictrel)));
+
+ initStringInfo(&querybuf);
+
+ /*
+ * Build and execute the CREATE TABLE query.
+ */
+ appendStringInfo(&querybuf,
+ "CREATE TABLE %s.%s ("
+ "relid Oid,"
+ "local_xid xid,"
+ "remote_xid xid,"
+ "local_lsn pg_lsn,"
+ "remote_commit_lsn pg_lsn,"
+ "local_commit_ts TIMESTAMPTZ,"
+ "remote_commit_ts TIMESTAMPTZ,"
+ "table_schema TEXT,"
+ "table_name TEXT,"
+ "conflict_type TEXT,"
+ "local_origin TEXT,"
+ "remote_origin TEXT,"
+ "key_tuple JSON,"
+ "local_tuple JSON,"
+ "remote_tuple JSON)",
+ quote_identifier(get_namespace_name(namespaceId)),
+ quote_identifier(conflictrel));
+
+ if (SPI_connect() != SPI_OK_CONNECT)
+ elog(ERROR, "SPI_connect failed");
+
+ if (SPI_execute(querybuf.data, false, 0) != SPI_OK_UTILITY)
+ elog(ERROR, "SPI_exec failed: %s", querybuf.data);
+
+ if (SPI_finish() != SPI_OK_FINISH)
+ elog(ERROR, "SPI_finish failed");
+
+ pfree(querybuf.data);
+}
+
/*
* Create new subscription.
*/
@@ -580,6 +655,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bits32 supported_opts;
SubOpts opts = {0};
AclResult aclresult;
+ Oid conflict_table_nspid;
+ char *conflict_table;
/*
* Parse and check options.
@@ -593,7 +670,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
SUBOPT_DISABLE_ON_ERR | SUBOPT_PASSWORD_REQUIRED |
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
- SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN);
+ SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_TABLE);
parse_subscription_options(pstate, stmt->options, supported_opts, &opts);
/*
@@ -728,6 +806,25 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
values[Anum_pg_subscription_suborigin - 1] =
CStringGetTextDatum(opts.origin);
+ /*
+ * If a conflict log history table name is specified, parse the schema and
+ * table name from the string. Store the namespace OID and the table name in
+ * the pg_subscription catalog tuple.
+ */
+ if (opts.conflicttable)
+ {
+ List *names = stringToQualifiedNameList(opts.conflicttable, NULL);
+
+ conflict_table_nspid =
+ QualifiedNameGetCreationNamespace(names, &conflict_table);
+ values[Anum_pg_subscription_subconflictnspid - 1] =
+ ObjectIdGetDatum(conflict_table_nspid);
+ values[Anum_pg_subscription_subconflicttable - 1] =
+ CStringGetTextDatum(conflict_table);
+ }
+ else
+ nulls[Anum_pg_subscription_subconflicttable - 1] = true;
+
tup = heap_form_tuple(RelationGetDescr(rel), values, nulls);
/* Insert tuple into catalog. */
@@ -739,6 +836,10 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
ReplicationOriginNameForLogicalRep(subid, InvalidOid, originname, sizeof(originname));
replorigin_create(originname);
+ /* If conflict log history table name is given than create the table. */
+ if (opts.conflicttable)
+ CreateConflictHistoryTable(conflict_table_nspid, conflict_table);
+
/*
* Connect to remote side to execute requested commands and fetch table
* info.
@@ -1272,7 +1373,8 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
SUBOPT_MAX_RETENTION_DURATION |
- SUBOPT_ORIGIN);
+ SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_TABLE);
parse_subscription_options(pstate, stmt->options,
supported_opts, &opts);
@@ -1527,6 +1629,25 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
origin = opts.origin;
}
+ if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_TABLE))
+ {
+ Oid nspid;
+ char *relname = NULL;
+ List *names =
+ stringToQualifiedNameList(opts.conflicttable, NULL);
+
+ nspid = QualifiedNameGetCreationNamespace(names, &relname);
+ values[Anum_pg_subscription_subconflictnspid - 1] =
+ ObjectIdGetDatum(nspid);
+ values[Anum_pg_subscription_subconflicttable - 1] =
+ CStringGetTextDatum(relname);
+
+ replaces[Anum_pg_subscription_subconflictnspid - 1] = true;
+ replaces[Anum_pg_subscription_subconflicttable - 1] = true;
+
+ CreateConflictHistoryTable(nspid, relname);
+ }
+
update_tuple = true;
break;
}
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 16695592265..b1658977aed 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -15,13 +15,23 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/tableam.h"
+#include "access/table.h"
+#include "catalog/indexing.h"
+#include "catalog/namespace.h"
+#include "catalog/pg_namespace_d.h"
+#include "catalog/pg_type.h"
#include "executor/executor.h"
+#include "executor/spi.h"
#include "pgstat.h"
#include "replication/conflict.h"
#include "replication/worker_internal.h"
#include "storage/lmgr.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/lsyscache.h"
+#include "utils/pg_lsn.h"
static const char *const ConflictTypeNames[] = {
[CT_INSERT_EXISTS] = "insert_exists",
@@ -52,6 +62,16 @@ static char *build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
Oid indexoid);
static char *build_index_value_desc(EState *estate, Relation localrel,
TupleTableSlot *slot, Oid indexoid);
+static Datum TupleTableSlotToJsonDatum(TupleTableSlot *slot);
+
+static void InsertConflictLog(Relation rel,
+ TransactionId local_xid,
+ TimestampTz local_ts,
+ ConflictType conflict_type,
+ RepOriginId origin_id,
+ TupleTableSlot *searchslot,
+ TupleTableSlot *localslot,
+ TupleTableSlot *remoteslot);
/*
* Get the xmin and commit timestamp data (origin and timestamp) associated
@@ -112,6 +132,7 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
/* Form errdetail message by combining conflicting tuples information. */
foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
+ {
errdetail_apply_conflict(estate, relinfo, type, searchslot,
conflicttuple->slot, remoteslot,
conflicttuple->indexoid,
@@ -120,6 +141,15 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
conflicttuple->ts,
&err_detail);
+ /* Insert conflict details to log history table. */
+ InsertConflictLog(relinfo->ri_RelationDesc,
+ conflicttuple->xmin,
+ conflicttuple->ts, type,
+ conflicttuple->origin,
+ searchslot, conflicttuple->slot,
+ remoteslot);
+ }
+
pgstat_report_subscription_conflict(MySubscription->oid, type);
ereport(elevel,
@@ -525,3 +555,143 @@ build_index_value_desc(EState *estate, Relation localrel, TupleTableSlot *slot,
return index_value;
}
+
+/*
+ * Helper function to convert a TupleTableSlot to Jsonb
+ *
+ * This would be a new internal helper function for logical replication
+ * Needs to handle various data types and potentially TOASTed data
+ */
+static Datum
+TupleTableSlotToJsonDatum(TupleTableSlot *slot)
+{
+ HeapTuple tuple = ExecCopySlotHeapTuple(slot);
+ Datum datum = heap_copy_tuple_as_datum(tuple, slot->tts_tupleDescriptor);
+ Datum json;
+
+ if (TupIsNull(slot))
+ return 0;
+
+ json = DirectFunctionCall1(row_to_json, datum);
+ heap_freetuple(tuple);
+
+ return json;
+}
+
+/*
+ * InsertConflictLog
+ *
+ * Insert details about a logical replication conflict to a conflict history
+ * table.
+ */
+static void
+InsertConflictLog(Relation rel, TransactionId local_xid, TimestampTz local_ts,
+ ConflictType conflict_type, RepOriginId origin_id,
+ TupleTableSlot *searchslot, TupleTableSlot *localslot,
+ TupleTableSlot *remoteslot)
+{
+ Datum values[MAX_CONFLICT_ATTR_NUM];
+ bool nulls[MAX_CONFLICT_ATTR_NUM];
+ Oid nspid;
+ Oid relid;
+ Relation conflictrel;
+ int attno;
+ int options = HEAP_INSERT_NO_LOGICAL;
+ char *relname;
+ char *origin = NULL;
+ char *remote_origin = NULL;
+ HeapTuple tup;
+ XLogRecPtr local_lsn = 0;
+
+ /* If conflict history is not enabled for the subscription just return. */
+ relname = get_subscription_conflictrel(MyLogicalRepWorker->subid, &nspid);
+ if (relname == NULL)
+ return;
+
+ /* TODO: proper error code */
+ relid = get_relname_relid(relname, nspid);
+ if (!OidIsValid(relid))
+ elog(ERROR, "conflict log history table does not exists");
+ conflictrel = table_open(relid, RowExclusiveLock);
+ if (conflictrel == NULL)
+ elog(ERROR, "could not open conflict log history table");
+
+
+ /* Initialize values and nulls arrays */
+ memset(values, 0, sizeof(Datum) * MAX_CONFLICT_ATTR_NUM);
+ memset(nulls, 0, sizeof(bool) * MAX_CONFLICT_ATTR_NUM);
+
+ /* Populate the values and nulls arrays */
+ attno = 0;
+ values[attno] = ObjectIdGetDatum(RelationGetRelid(rel));
+ attno++;
+
+ values[attno] = TransactionIdGetDatum(local_xid);
+ attno++;
+
+ values[attno] = TransactionIdGetDatum(remote_xid);
+ attno++;
+
+ values[attno] = LSNGetDatum(local_lsn);
+ attno++;
+
+ values[attno] = LSNGetDatum(remote_final_lsn);
+ attno++;
+
+ values[attno] = TimestampTzGetDatum(local_ts);
+ attno++;
+
+ values[attno] = TimestampTzGetDatum(remote_commit_ts);
+ attno++;
+
+ values[attno] =
+ CStringGetTextDatum(get_namespace_name(RelationGetNamespace(rel)));
+ attno++;
+
+ values[attno] = CStringGetTextDatum(RelationGetRelationName(rel));
+ attno++;
+
+ values[attno] = CStringGetTextDatum(ConflictTypeNames[conflict_type]);
+ attno++;
+
+ if (origin_id != InvalidRepOriginId)
+ replorigin_by_oid(origin_id, true, &origin);
+
+ if (origin != NULL)
+ values[attno] = CStringGetTextDatum(origin);
+ else
+ nulls[attno] = true;
+ attno++;
+
+ if (replorigin_session_origin != InvalidRepOriginId)
+ replorigin_by_oid(replorigin_session_origin, true, &remote_origin);
+
+ if (remote_origin != NULL)
+ values[attno] = CStringGetTextDatum(remote_origin);
+ else
+ nulls[attno] = true;
+ attno++;
+
+ if (searchslot != NULL)
+ values[attno] = TupleTableSlotToJsonDatum(searchslot);
+ else
+ nulls[attno] = true;
+ attno++;
+
+ if (localslot != NULL)
+ values[attno] = TupleTableSlotToJsonDatum(localslot);
+ else
+ nulls[attno] = true;
+ attno++;
+
+ if (remoteslot != NULL)
+ values[attno] = TupleTableSlotToJsonDatum(remoteslot);
+ else
+ nulls[attno] = true;
+
+ tup = heap_form_tuple(RelationGetDescr(conflictrel), values, nulls);
+ heap_insert(conflictrel, tup, GetCurrentCommandId(true), options, NULL);
+ table_close(conflictrel, RowExclusiveLock);
+
+ pfree(relname);
+}
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 419e478b4c6..ffd40857329 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -482,7 +482,9 @@ static bool MySubscriptionValid = false;
static List *on_commit_wakeup_workers_subids = NIL;
bool in_remote_transaction = false;
-static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+TransactionId remote_xid = InvalidTransactionId;
+TimestampTz remote_commit_ts = 0;
/* fields valid only when processing streamed transaction */
static bool in_streamed_transaction = false;
@@ -1213,6 +1215,8 @@ apply_handle_begin(StringInfo s)
set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
remote_final_lsn = begin_data.final_lsn;
+ remote_commit_ts = begin_data.committime;
+ remote_xid = begin_data.xid;
maybe_start_skipping_changes(begin_data.final_lsn);
@@ -1724,6 +1728,10 @@ apply_handle_stream_start(StringInfo s)
/* extract XID of the top-level transaction */
stream_xid = logicalrep_read_stream_start(s, &first_segment);
+ remote_xid = stream_xid;
+ remote_final_lsn = InvalidXLogRecPtr;
+ remote_commit_ts = 0;
+
if (!TransactionIdIsValid(stream_xid))
ereport(ERROR,
(errcode(ERRCODE_PROTOCOL_VIOLATION),
diff --git a/src/backend/utils/cache/lsyscache.c b/src/backend/utils/cache/lsyscache.c
index fa7cd7e06a7..abecdf9f6dc 100644
--- a/src/backend/utils/cache/lsyscache.c
+++ b/src/backend/utils/cache/lsyscache.c
@@ -3881,3 +3881,40 @@ get_subscription_name(Oid subid, bool missing_ok)
return subname;
}
+
+/*
+ * get_subscription_conflictrel
+ *
+ * Get conflict relation name and namespace id from subscription.
+ */
+char *
+get_subscription_conflictrel(Oid subid, Oid *nspid)
+{
+ HeapTuple tup;
+ Datum datum;
+ bool isnull;
+ char *relname;
+ Form_pg_subscription subform;
+
+ tup = SearchSysCache1(SUBSCRIPTIONOID, ObjectIdGetDatum(subid));
+
+ if (!HeapTupleIsValid(tup))
+ return NULL;
+
+ subform = (Form_pg_subscription) GETSTRUCT(tup);
+
+ /* Get conflict table name */
+ datum = SysCacheGetAttr(SUBSCRIPTIONOID,
+ tup,
+ Anum_pg_subscription_subconflicttable,
+ &isnull);
+ if (isnull)
+ return NULL;
+
+ *nspid = subform->subconflictnspid;
+ relname = pstrdup(TextDatumGetCString(datum));
+
+ ReleaseSysCache(tup);
+
+ return relname;
+}
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index 55cb9b1eefa..ec31e2b1d56 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -80,6 +80,8 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
bool subretaindeadtuples; /* True if dead tuples useful for
* conflict detection are retained */
+ Oid subconflictnspid; /* Namespace Oid in which the conflict history
+ * table is created. */
int32 submaxretention; /* The maximum duration (in milliseconds)
* for which information useful for
@@ -105,6 +107,9 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
/* Only publish data originating from the specified origin */
text suborigin BKI_DEFAULT(LOGICALREP_ORIGIN_ANY);
+
+ /* conflict log history table name if valid */
+ text subconflicttable;
#endif
} FormData_pg_subscription;
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index c8fbf9e51b8..adc46e79286 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -62,6 +62,7 @@ typedef enum
} ConflictType;
#define CONFLICT_NUM_TYPES (CT_MULTIPLE_UNIQUE_CONFLICTS + 1)
+#define MAX_CONFLICT_ATTR_NUM 15
/*
* Information for the existing local row that caused the conflict.
@@ -89,4 +90,5 @@ extern void ReportApplyConflict(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *remoteslot,
List *conflicttuples);
extern void InitConflictIndexes(ResultRelInfo *relInfo);
+
#endif
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index de003802612..84bd6383615 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -251,6 +251,10 @@ extern PGDLLIMPORT bool in_remote_transaction;
extern PGDLLIMPORT bool InitializingApplyWorker;
+extern XLogRecPtr remote_final_lsn;
+extern TimestampTz remote_commit_ts;
+extern TransactionId remote_xid;
+
extern void logicalrep_worker_attach(int slot);
extern LogicalRepWorker *logicalrep_worker_find(Oid subid, Oid relid,
bool only_running);
diff --git a/src/include/utils/lsyscache.h b/src/include/utils/lsyscache.h
index 50fb149e9ac..dc6df5843a4 100644
--- a/src/include/utils/lsyscache.h
+++ b/src/include/utils/lsyscache.h
@@ -210,6 +210,7 @@ extern Oid get_publication_oid(const char *pubname, bool missing_ok);
extern char *get_publication_name(Oid pubid, bool missing_ok);
extern Oid get_subscription_oid(const char *subname, bool missing_ok);
extern char *get_subscription_name(Oid subid, bool missing_ok);
+extern char *get_subscription_conflictrel(Oid subid, Oid *nspid);
#define type_is_array(typid) (get_element_type(typid) != InvalidOid)
/* type_is_array_domain accepts both plain arrays and domains over arrays */
--
2.49.0
[application/octet-stream] v2-0002-Don-t-add-conflict-history-tables-to-publishable-.patch (3.3K, ../../CAFiTN-uSs5kiJ6cMq9VZ03+wwOxGpsh707XX+1sWNO-9NcyXrA@mail.gmail.com/3-v2-0002-Don-t-add-conflict-history-tables-to-publishable-.patch)
download | inline diff:
From 26caa46a978e09469070a8ad3f0bdc0d77b233df Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Mon, 29 Sep 2025 14:36:38 +0530
Subject: [PATCH v2 2/2] Don't add conflict history tables to publishable
relation
When all table option is used with publication don't publish the
conflict history tables.
---
src/backend/catalog/pg_publication.c | 3 ++
src/backend/commands/subscriptioncmds.c | 40 +++++++++++++++++++++++++
src/include/commands/subscriptioncmds.h | 2 ++
3 files changed, 45 insertions(+)
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index b911efcf9cb..1e4ce2d6116 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -31,6 +31,7 @@
#include "catalog/pg_publication_rel.h"
#include "catalog/pg_type.h"
#include "commands/publicationcmds.h"
+#include "commands/subscriptioncmds.h"
#include "funcapi.h"
#include "utils/array.h"
#include "utils/builtins.h"
@@ -883,7 +884,9 @@ GetAllTablesPublicationRelations(bool pubviaroot)
Form_pg_class relForm = (Form_pg_class) GETSTRUCT(tuple);
Oid relid = relForm->oid;
+ /* conflict history tables are not published. */
if (is_publishable_class(relid, relForm) &&
+ !IsConflictHistoryRelid(relid) &&
!(relForm->relispartition && pubviaroot))
result = lappend_oid(result, relid);
}
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index dbaeed4b0b1..f5d04c4d9cc 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -15,6 +15,7 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/htup_details.h"
#include "access/table.h"
#include "access/twophase.h"
@@ -3024,3 +3025,42 @@ defGetStreamingMode(DefElem *def)
def->defname)));
return LOGICALREP_STREAM_OFF; /* keep compiler quiet */
}
+
+/*
+ * IsConflictHistoryRelid - is this relid used as conflict history table
+ *
+ * Scan all the subscription and check whether the relation is used as
+ * conflict history table.
+ */
+bool
+IsConflictHistoryRelid(Oid relid)
+{
+ Relation rel;
+ TableScanDesc scan;
+ HeapTuple tup;
+ bool found = false;
+
+ rel = table_open(SubscriptionRelationId, AccessShareLock);
+ scan = table_beginscan_catalog(rel, 0, NULL);
+
+ while (HeapTupleIsValid(tup = heap_getnext(scan, ForwardScanDirection)))
+ {
+ Form_pg_subscription subform = (Form_pg_subscription) GETSTRUCT(tup);
+ Oid nspid;
+ char *relname;
+
+ relname = get_subscription_conflictrel(subform->oid, &nspid);
+ if (relname == NULL)
+ continue;
+ if (relid == get_relname_relid(relname, nspid))
+ {
+ found = true;
+ break;
+ }
+ }
+
+ table_endscan(scan);
+ table_close(rel, AccessShareLock);
+
+ return found;
+}
diff --git a/src/include/commands/subscriptioncmds.h b/src/include/commands/subscriptioncmds.h
index fb4e26a51a4..550af0bb034 100644
--- a/src/include/commands/subscriptioncmds.h
+++ b/src/include/commands/subscriptioncmds.h
@@ -36,4 +36,6 @@ extern void CheckSubDeadTupleRetention(bool check_guc, bool sub_disabled,
bool retention_active,
bool max_retention_set);
+extern bool IsConflictHistoryRelid(Oid relid);
+
#endif /* SUBSCRIPTIONCMDS_H */
--
2.49.0
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-11-11 10:18 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: shveta malik @ 2025-11-11 10:18 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Mon, Sep 29, 2025 at 3:27 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Sun, Sep 28, 2025 at 5:15 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Sun, Sep 28, 2025 at 2:43 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > >
> >
> > > I agree that option-2 is useful and IIUC, we are already working on
> > > something similar in thread [1]. However, it is better to use option-1
> > > here because we are using non-user specified mechanism to skip changes
> > > during replication, so following the same during other times is
> > > preferable. Once we have that other feature [1], we can probably
> > > optimize this code to use it without taking input from the user. The
> > > other reason of not going with the option-2 in the way you are
> > > proposing is that it doesn't seem like a good idea to have multiple
> > > ways to specify skipping tables from publishing. I find the approach
> > > being discussed in thread [1] a generic and better than a new
> > > table-level option.
> > >
> > > [1] - https://www.postgresql.org/message-id/CANhcyEVt2CBnG7MOktaPPV4rYapHR-VHe5%3DqoziTZh1L9SVc6w%40mail.g...
> >
> > I understand the current discussion revolves around using an EXCEPT
> > clause (for tables/schemas/columns) during publication creation. But
> > what we want is to mark some table which will be excluded permanently
> > from publication, because we can not expect users to explicitly
> > exclude them while creating publication.
> >
> > So, I propose we add a "non-publishable" property to tables
> > themselves. This is a more valuable option for users who are certain
> > that certain tables should never be replicated.
> >
> > By marking a table as non-publishable, we save users the effort of
> > repeatedly listing it in the EXCEPT option for every new publication.
> > Both methods have merit, but the proposed table property addresses the
> > need for a permanent, system-wide exclusion.
> >
> > See below test with a quick hack, what I am referring to.
> >
> > postgres[2730657]=# CREATE TABLE test(a int) WITH
> > (NON_PUBLISHABLE_TABLE = true);
> > CREATE TABLE
> > postgres[2730657]=# CREATE PUBLICATION pub FOR ALL TABLES ;
> > CREATE PUBLICATION
> > postgres[2730657]=# select pg_get_publication_tables('pub');
> > pg_get_publication_tables
> > ---------------------------
> > (0 rows)
> >
> >
> > But I agree this is an additional table option which might need
> > consensus, so meanwhile we can proceed with option2, I will prepare
> > patches with option-2 and as a add on patch I will propose option-1.
> > And this option-1 patch can be discussed in a separate thread as well.
>
> So here is the patch set using option-2, with this when alltable
> option is used and we get pg_get_publication_tables(), this will check
> the relid against the conflict history tables in the subscribers and
> those tables will not be added to the list. I will start a separate
> thread for proposing the patch I sent in previous email.
>
I have started going through this thread. Is it possible to rebase the
patches and post?
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-11-11 10:26 Dilip Kumar <dilipbalaut@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 0 replies; 912+ messages in thread
From: Dilip Kumar @ 2025-11-11 10:26 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, Nov 11, 2025 at 3:49 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> On Mon, Sep 29, 2025 at 3:27 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
>
> I have started going through this thread. Is it possible to rebase the
> patches and post?
Thanks Shveta, I will post the rebased patch by tomorrow.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-11-12 06:50 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: shveta malik @ 2025-11-12 06:50 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Fri, Sep 26, 2025 at 4:42 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Thu, Sep 25, 2025 at 4:19 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Thu, Sep 25, 2025 at 11:53 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > > [1]
> > > > /*
> > > > * For logical decode we need combo CIDs to properly decode the
> > > > * catalog
> > > > */
> > > > if (RelationIsAccessibleInLogicalDecoding(relation))
> > > > log_heap_new_cid(relation, &tp);
> > > >
> > >
> > > Meanwhile I am also exploring the option where we can just CREATE TYPE
> > > in initialize_data_directory() during initdb, basically we will create
> > > this type in template1 so that it will be available in all the
> > > databases, and that would simplify the table creation whether we
> > > create internally or we allow user to create it. And while checking
> > > is_publishable_class we can check the type and avoid publishing those
> > > tables.
> > >
> >
> > Based on my off list discussion with Amit, one option could be to set
> > HEAP_INSERT_NO_LOGICAL option while inserting tuple into conflict
> > history table, for that we can not use SPI interface to insert instead
> > we will have to directly call the heap_insert() to add this option.
> > Since we do not want to create any trigger etc on this table, direct
> > insert should be fine, but if we plan to create this table as
> > partitioned table in future then direct heap insert might not work.
>
> Upon further reflection, I realized that while this approach avoids
> streaming inserts to the conflict log history table, it still requires
> that table to exist on the subscriber node upon subscription creation,
> which isn't ideal.
>
> We have two main options to address this:
>
> Option1:
> When calling pg_get_publication_tables(), if the 'alltables' option is
> used, we can scan all subscriptions and explicitly ignore (filter out)
> all conflict history tables. This will not be very costly as this
> will scan the subscriber when pg_get_publication_tables() is called,
> which is only called during create subscription/alter subscription on
> the remote node.
>
> Option2:
> Alternatively, we could introduce a table creation option, like a
> 'non-publishable' flag, to prevent a table from being streamed
> entirely. I believe this would be a valuable, independent feature for
> users who want to create certain tables without including them in
> logical replication.
>
> I prefer option2, as I feel this can add value independent of this patch.
>
I agree that marking tables with a flag to easily exclude them during
publishing would be cleaner. In the current patch, for an ALL-TABLES
publication, we scan pg_subscription for each table in pg_class to
check its subconflicttable and decide whether to ignore it. But since
this only happens during create/alter subscription and refresh
publication, the overhead should be acceptable.
Introducing a ‘NON_PUBLISHABLE_TABLE’ option would be a good
enhancement but since we already have the EXCEPT list built in a
separate thread, that might be sufficient for now. IMO, such
conflict-tables should be marked internally (for example, with a
‘non_publishable’ or ‘conflict_log_table’ flag) so they can be easily
identified within the system, without requiring users to explicitly
specify them in EXCEPT or as NON_PUBLISHABLE_TABLE. I would like to
see what others think on this.
For the time being, the current implementation looks fine, considering
it runs only during a few publication-related DDL operations.
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-11-12 09:10 Dilip Kumar <dilipbalaut@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-11-12 09:10 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Wed, Nov 12, 2025 at 12:21 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> On Fri, Sep 26, 2025 at 4:42 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> I agree that marking tables with a flag to easily exclude them during
> publishing would be cleaner. In the current patch, for an ALL-TABLES
> publication, we scan pg_subscription for each table in pg_class to
> check its subconflicttable and decide whether to ignore it. But since
> this only happens during create/alter subscription and refresh
> publication, the overhead should be acceptable.
Thanks for your opinion.
> Introducing a ‘NON_PUBLISHABLE_TABLE’ option would be a good
> enhancement but since we already have the EXCEPT list built in a
> separate thread, that might be sufficient for now. IMO, such
> conflict-tables should be marked internally (for example, with a
> ‘non_publishable’ or ‘conflict_log_table’ flag) so they can be easily
> identified within the system, without requiring users to explicitly
> specify them in EXCEPT or as NON_PUBLISHABLE_TABLE. I would like to
> see what others think on this.
> For the time being, the current implementation looks fine, considering
> it runs only during a few publication-related DDL operations.
+1
Here is the rebased patch, changes apart from rebasing it
1) Dropped the conflict history table during drop subscription
2) Added test cases for testing the conflict history table behavior
with CREATE/ALTER/DROP subscription
TODO:
1) Need more thoughts on the table schema whether we need to capture
more items or shall we drop some fields if we think those are not
necessary.
2) Logical replication test for generating conflict and capturing in
conflict history table.
--
Regards,
Dilip Kumar
Google
Attachments:
[application/octet-stream] v3-0002-Don-t-add-conflict-history-tables-to-publishable-.patch (3.2K, ../../CAFiTN-t82BiXen+HfdR9jZyOpuSO92xonnUK=khXsiZWBfOxMA@mail.gmail.com/2-v3-0002-Don-t-add-conflict-history-tables-to-publishable-.patch)
download | inline diff:
From 830477d8d5eb57aeda59c8e9c6850794bcd3e6ad Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 12 Nov 2025 14:30:39 +0530
Subject: [PATCH v3 2/2] Don't add conflict history tables to publishable
relation
When all table option is used with publication don't publish the
conflict history tables.
---
src/backend/catalog/pg_publication.c | 3 ++
src/backend/commands/subscriptioncmds.c | 40 +++++++++++++++++++++++++
src/include/commands/subscriptioncmds.h | 2 ++
3 files changed, 45 insertions(+)
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index ac2f4ee3561..41f9fe78f5c 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -31,6 +31,7 @@
#include "catalog/pg_publication_rel.h"
#include "catalog/pg_type.h"
#include "commands/publicationcmds.h"
+#include "commands/subscriptioncmds.h"
#include "funcapi.h"
#include "utils/array.h"
#include "utils/builtins.h"
@@ -890,7 +891,9 @@ GetAllPublicationRelations(char relkind, bool pubviaroot)
Form_pg_class relForm = (Form_pg_class) GETSTRUCT(tuple);
Oid relid = relForm->oid;
+ /* conflict history tables are not published. */
if (is_publishable_class(relid, relForm) &&
+ !IsConflictHistoryRelid(relid) &&
!(relForm->relispartition && pubviaroot))
result = lappend_oid(result, relid);
}
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index a5dc9a11c60..4eb140eb357 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -15,6 +15,7 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/htup_details.h"
#include "access/table.h"
#include "access/twophase.h"
@@ -3359,3 +3360,42 @@ DropConflictHistoryTable(Oid namespaceId, char *conflictrel)
pfree(querybuf.data);
}
+
+/*
+ * Is relation used as a conflict history table
+ *
+ * Scan all the subscription and check whether the relation is used as
+ * conflict history table.
+ */
+bool
+IsConflictHistoryRelid(Oid relid)
+{
+ Relation rel;
+ TableScanDesc scan;
+ HeapTuple tup;
+ bool found = false;
+
+ rel = table_open(SubscriptionRelationId, AccessShareLock);
+ scan = table_beginscan_catalog(rel, 0, NULL);
+
+ while (HeapTupleIsValid(tup = heap_getnext(scan, ForwardScanDirection)))
+ {
+ Form_pg_subscription subform = (Form_pg_subscription) GETSTRUCT(tup);
+ Oid nspid;
+ char *relname;
+
+ relname = get_subscription_conflictrel(subform->oid, &nspid);
+ if (relname == NULL)
+ continue;
+ if (relid == get_relname_relid(relname, nspid))
+ {
+ found = true;
+ break;
+ }
+ }
+
+ table_endscan(scan);
+ table_close(rel, AccessShareLock);
+
+ return found;
+}
diff --git a/src/include/commands/subscriptioncmds.h b/src/include/commands/subscriptioncmds.h
index fb4e26a51a4..550af0bb034 100644
--- a/src/include/commands/subscriptioncmds.h
+++ b/src/include/commands/subscriptioncmds.h
@@ -36,4 +36,6 @@ extern void CheckSubDeadTupleRetention(bool check_guc, bool sub_disabled,
bool retention_active,
bool max_retention_set);
+extern bool IsConflictHistoryRelid(Oid relid);
+
#endif /* SUBSCRIPTIONCMDS_H */
--
2.49.0
[application/octet-stream] v3-0001-Add-configurable-conflict-log-history-table-for.patch (31.4K, ../../CAFiTN-t82BiXen+HfdR9jZyOpuSO92xonnUK=khXsiZWBfOxMA@mail.gmail.com/3-v3-0001-Add-configurable-conflict-log-history-table-for.patch)
download | inline diff:
From 5305d9295ffc404fa593bb318dea66a0f8a2f607 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 12 Nov 2025 10:43:19 +0530
Subject: [PATCH v3 1/2] Add configurable conflict log history table for
Logical Replication
This patch adds a feature to provide a structured, queryable record of all
logical replication conflicts. The current approach of logging conflicts as
plain text in the server logs makes it difficult to query, analyze, and
use for external monitoring and automation.
This patch addresses these limitations by introducing a configurable
conflict_log_table option in the CREATE SUBSCRIPTION command. Key design
decisions include:
User-Defined Table: The conflict log is stored in a user-managed table
rather than a system catalog.
Structured Data: Conflict details, including the original and remote tuples,
are stored in JSON columns, providing a flexible format to accommodate different
table schemas.
Comprehensive Information: The log table captures essential attributes such as
local and remote transaction IDs, LSNs, commit timestamps, and conflict type,
providing a complete record for post-mortem analysis.
This feature will make logical replication conflicts easier to monitor and manage,
significantly improving the overall resilience and operability of replication setups.
---
src/backend/commands/subscriptioncmds.c | 177 ++++++++++++++++++++-
src/backend/replication/logical/conflict.c | 166 +++++++++++++++++++
src/backend/replication/logical/worker.c | 10 +-
src/backend/utils/cache/lsyscache.c | 40 +++++
src/include/catalog/pg_subscription.h | 5 +
src/include/replication/conflict.h | 2 +
src/include/replication/worker_internal.h | 4 +
src/include/utils/lsyscache.h | 1 +
src/test/regress/expected/subscription.out | 79 +++++++++
src/test/regress/sql/subscription.sql | 55 +++++++
10 files changed, 535 insertions(+), 4 deletions(-)
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index 5930e8c5816..a5dc9a11c60 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -34,6 +34,7 @@
#include "commands/event_trigger.h"
#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "executor/spi.h"
#include "miscadmin.h"
#include "nodes/makefuncs.h"
#include "pgstat.h"
@@ -47,10 +48,12 @@
#include "storage/lmgr.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/guc.h"
#include "utils/lsyscache.h"
#include "utils/memutils.h"
#include "utils/pg_lsn.h"
+#include "utils/regproc.h"
#include "utils/syscache.h"
/*
@@ -75,6 +78,7 @@
#define SUBOPT_MAX_RETENTION_DURATION 0x00008000
#define SUBOPT_LSN 0x00010000
#define SUBOPT_ORIGIN 0x00020000
+#define SUBOPT_CONFLICT_TABLE 0x00030000
/* check if the 'val' has 'bits' set */
#define IsSet(val, bits) (((val) & (bits)) == (bits))
@@ -103,6 +107,7 @@ typedef struct SubOpts
bool retaindeadtuples;
int32 maxretention;
char *origin;
+ char *conflicttable;
XLogRecPtr lsn;
} SubOpts;
@@ -135,7 +140,8 @@ static List *merge_publications(List *oldpublist, List *newpublist, bool addpub,
static void ReportSlotConnectionError(List *rstates, Oid subid, char *slotname, char *err);
static void CheckAlterSubOption(Subscription *sub, const char *option,
bool slot_needs_update, bool isTopLevel);
-
+static void CreateConflictHistoryTable(Oid namespaceId, char *conflictrel);
+static void DropConflictHistoryTable(Oid namespaceId, char *conflictrel);
/*
* Common option parsing function for CREATE and ALTER SUBSCRIPTION commands.
@@ -191,6 +197,8 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->maxretention = 0;
if (IsSet(supported_opts, SUBOPT_ORIGIN))
opts->origin = pstrdup(LOGICALREP_ORIGIN_ANY);
+ if (IsSet(supported_opts, SUBOPT_CONFLICT_TABLE))
+ opts->conflicttable = NULL;
/* Parse options */
foreach(lc, stmt_options)
@@ -402,6 +410,15 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->specified_opts |= SUBOPT_LSN;
opts->lsn = lsn;
}
+ else if (IsSet(supported_opts, SUBOPT_CONFLICT_TABLE) &&
+ strcmp(defel->defname, "conflict_log_table") == 0)
+ {
+ if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_TABLE))
+ errorConflictingDefElem(defel, pstate);
+
+ opts->specified_opts |= SUBOPT_CONFLICT_TABLE;
+ opts->conflicttable = defGetString(defel);
+ }
else
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
@@ -599,6 +616,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bits32 supported_opts;
SubOpts opts = {0};
AclResult aclresult;
+ Oid conflict_table_nspid;
+ char *conflict_table;
/*
* Parse and check options.
@@ -612,7 +631,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
SUBOPT_DISABLE_ON_ERR | SUBOPT_PASSWORD_REQUIRED |
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
- SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN);
+ SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_TABLE);
parse_subscription_options(pstate, stmt->options, supported_opts, &opts);
/*
@@ -747,6 +767,25 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
values[Anum_pg_subscription_suborigin - 1] =
CStringGetTextDatum(opts.origin);
+ /*
+ * If a conflict log history table name is specified, parse the schema and
+ * table name from the string. Store the namespace OID and the table name in
+ * the pg_subscription catalog tuple.
+ */
+ if (opts.conflicttable)
+ {
+ List *names = stringToQualifiedNameList(opts.conflicttable, NULL);
+
+ conflict_table_nspid =
+ QualifiedNameGetCreationNamespace(names, &conflict_table);
+ values[Anum_pg_subscription_subconflictnspid - 1] =
+ ObjectIdGetDatum(conflict_table_nspid);
+ values[Anum_pg_subscription_subconflicttable - 1] =
+ CStringGetTextDatum(conflict_table);
+ }
+ else
+ nulls[Anum_pg_subscription_subconflicttable - 1] = true;
+
tup = heap_form_tuple(RelationGetDescr(rel), values, nulls);
/* Insert tuple into catalog. */
@@ -768,6 +807,10 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
ReplicationOriginNameForLogicalRep(subid, InvalidOid, originname, sizeof(originname));
replorigin_create(originname);
+ /* If conflict log history table name is given than create the table. */
+ if (opts.conflicttable)
+ CreateConflictHistoryTable(conflict_table_nspid, conflict_table);
+
/*
* Connect to remote side to execute requested commands and fetch table
* and sequence info.
@@ -1410,7 +1453,8 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
SUBOPT_MAX_RETENTION_DURATION |
- SUBOPT_ORIGIN);
+ SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_TABLE);
parse_subscription_options(pstate, stmt->options,
supported_opts, &opts);
@@ -1665,6 +1709,25 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
origin = opts.origin;
}
+ if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_TABLE))
+ {
+ Oid nspid;
+ char *relname = NULL;
+ List *names =
+ stringToQualifiedNameList(opts.conflicttable, NULL);
+
+ nspid = QualifiedNameGetCreationNamespace(names, &relname);
+ values[Anum_pg_subscription_subconflictnspid - 1] =
+ ObjectIdGetDatum(nspid);
+ values[Anum_pg_subscription_subconflicttable - 1] =
+ CStringGetTextDatum(relname);
+
+ replaces[Anum_pg_subscription_subconflictnspid - 1] = true;
+ replaces[Anum_pg_subscription_subconflicttable - 1] = true;
+
+ CreateConflictHistoryTable(nspid, relname);
+ }
+
update_tuple = true;
break;
}
@@ -2027,6 +2090,8 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
Form_pg_subscription form;
List *rstates;
bool must_use_password;
+ Oid conflict_table_nsp = InvalidOid;
+ char *conflict_table = NULL;
/*
* The launcher may concurrently start a new worker for this subscription.
@@ -2110,6 +2175,19 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
ObjectAddressSet(myself, SubscriptionRelationId, subid);
EventTriggerSQLDropAddObject(&myself, true, true);
+ /* Fetch the conflict log table information. */
+ conflict_table = get_subscription_conflictrel(subid, &conflict_table_nsp);
+
+ /*
+ * If the subscription had a conflict log history table, drop it now.
+ * This happens before deleting the subscription tuple.
+ */
+ if (conflict_table)
+ {
+ DropConflictHistoryTable(conflict_table_nsp, conflict_table);
+ pfree(conflict_table);
+ }
+
/* Remove the tuple from catalog. */
CatalogTupleDelete(rel, &tup->t_self);
@@ -3188,3 +3266,96 @@ defGetStreamingMode(DefElem *def)
def->defname)));
return LOGICALREP_STREAM_OFF; /* keep compiler quiet */
}
+
+/*
+ * Create conflict log history table.
+ *
+ * The subscription owner becomes the owner of this table and has all
+ * privileges on it.
+ */
+static void
+CreateConflictHistoryTable(Oid namespaceId, char *conflictrel)
+{
+ StringInfoData querybuf;
+
+ /*
+ * Check if table with same name already present, if so report an error
+ * as currently we do not support user created table as conflict history
+ * table.
+ */
+ if (OidIsValid(get_relname_relid(conflictrel, namespaceId)))
+ ereport(ERROR,
+ (errcode(ERRCODE_DUPLICATE_TABLE),
+ errmsg("cannot create conflict history table \"%s.%s\" because a table with that name already exists",
+ get_namespace_name(namespaceId), conflictrel),
+ errhint("Use a different name for the conflict history table or drop the existing table.")));
+
+ initStringInfo(&querybuf);
+
+ /* build and execute the CREATE TABLE query. */
+ appendStringInfo(&querybuf,
+ "CREATE TABLE %s.%s ("
+ "relid Oid,"
+ "local_xid xid,"
+ "remote_xid xid,"
+ "remote_commit_lsn pg_lsn,"
+ "local_commit_ts TIMESTAMPTZ,"
+ "remote_commit_ts TIMESTAMPTZ,"
+ "table_schema TEXT,"
+ "table_name TEXT,"
+ "conflict_type TEXT,"
+ "local_origin TEXT,"
+ "remote_origin TEXT,"
+ "key_tuple JSON,"
+ "local_tuple JSON,"
+ "remote_tuple JSON)",
+ quote_identifier(get_namespace_name(namespaceId)),
+ quote_identifier(conflictrel));
+
+ if (SPI_connect() != SPI_OK_CONNECT)
+ elog(ERROR, "SPI_connect failed");
+
+ if (SPI_execute(querybuf.data, false, 0) != SPI_OK_UTILITY)
+ elog(ERROR, "SPI_exec failed: %s", querybuf.data);
+
+ if (SPI_finish() != SPI_OK_FINISH)
+ elog(ERROR, "SPI_finish failed");
+
+ pfree(querybuf.data);
+}
+
+/*
+ * Drop the conflict log history table.
+ *
+ * This function uses SPI to execute DROP TABLE IF EXISTS.
+ * We use IF EXISTS to avoid errors if the user manually dropped it first.
+ */
+static void
+DropConflictHistoryTable(Oid namespaceId, char *conflictrel)
+{
+ StringInfoData querybuf;
+
+ initStringInfo(&querybuf);
+
+ /*
+ * Use DROP TABLE IF EXISTS and quote the identifiers to handle case-sensitive
+ * or non-simple names. We use RESTRICT (default) since the table should
+ * not have external dependencies preventing its removal, but IF EXISTS
+ * ensures the command won't error if the table is already gone.
+ */
+ appendStringInfo(&querybuf,
+ "DROP TABLE IF EXISTS %s.%s",
+ quote_identifier(get_namespace_name(namespaceId)),
+ quote_identifier(conflictrel));
+
+ if (SPI_connect() != SPI_OK_CONNECT)
+ elog(ERROR, "SPI_connect failed");
+
+ if (SPI_execute(querybuf.data, false, 0) != SPI_OK_UTILITY)
+ elog(ERROR, "SPI_exec failed: %s", querybuf.data);
+
+ if (SPI_finish() != SPI_OK_FINISH)
+ elog(ERROR, "SPI_finish failed");
+
+ pfree(querybuf.data);
+}
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 16695592265..1d9f8fabe6f 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -15,13 +15,23 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/tableam.h"
+#include "access/table.h"
+#include "catalog/indexing.h"
+#include "catalog/namespace.h"
+#include "catalog/pg_namespace_d.h"
+#include "catalog/pg_type.h"
#include "executor/executor.h"
+#include "executor/spi.h"
#include "pgstat.h"
#include "replication/conflict.h"
#include "replication/worker_internal.h"
#include "storage/lmgr.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/lsyscache.h"
+#include "utils/pg_lsn.h"
static const char *const ConflictTypeNames[] = {
[CT_INSERT_EXISTS] = "insert_exists",
@@ -52,6 +62,16 @@ static char *build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
Oid indexoid);
static char *build_index_value_desc(EState *estate, Relation localrel,
TupleTableSlot *slot, Oid indexoid);
+static Datum TupleTableSlotToJsonDatum(TupleTableSlot *slot);
+
+static void InsertConflictLog(Relation rel,
+ TransactionId local_xid,
+ TimestampTz local_ts,
+ ConflictType conflict_type,
+ RepOriginId origin_id,
+ TupleTableSlot *searchslot,
+ TupleTableSlot *localslot,
+ TupleTableSlot *remoteslot);
/*
* Get the xmin and commit timestamp data (origin and timestamp) associated
@@ -112,6 +132,7 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
/* Form errdetail message by combining conflicting tuples information. */
foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
+ {
errdetail_apply_conflict(estate, relinfo, type, searchslot,
conflicttuple->slot, remoteslot,
conflicttuple->indexoid,
@@ -120,6 +141,15 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
conflicttuple->ts,
&err_detail);
+ /* Insert conflict details to log history table. */
+ InsertConflictLog(relinfo->ri_RelationDesc,
+ conflicttuple->xmin,
+ conflicttuple->ts, type,
+ conflicttuple->origin,
+ searchslot, conflicttuple->slot,
+ remoteslot);
+ }
+
pgstat_report_subscription_conflict(MySubscription->oid, type);
ereport(elevel,
@@ -525,3 +555,139 @@ build_index_value_desc(EState *estate, Relation localrel, TupleTableSlot *slot,
return index_value;
}
+
+/*
+ * Helper function to convert a TupleTableSlot to Jsonb
+ *
+ * This would be a new internal helper function for logical replication
+ * Needs to handle various data types and potentially TOASTed data
+ */
+static Datum
+TupleTableSlotToJsonDatum(TupleTableSlot *slot)
+{
+ HeapTuple tuple = ExecCopySlotHeapTuple(slot);
+ Datum datum = heap_copy_tuple_as_datum(tuple, slot->tts_tupleDescriptor);
+ Datum json;
+
+ if (TupIsNull(slot))
+ return 0;
+
+ json = DirectFunctionCall1(row_to_json, datum);
+ heap_freetuple(tuple);
+
+ return json;
+}
+
+/*
+ * InsertConflictLog
+ *
+ * Insert details about a logical replication conflict to a conflict history
+ * table.
+ */
+static void
+InsertConflictLog(Relation rel, TransactionId local_xid, TimestampTz local_ts,
+ ConflictType conflict_type, RepOriginId origin_id,
+ TupleTableSlot *searchslot, TupleTableSlot *localslot,
+ TupleTableSlot *remoteslot)
+{
+ Datum values[MAX_CONFLICT_ATTR_NUM];
+ bool nulls[MAX_CONFLICT_ATTR_NUM];
+ Oid nspid;
+ Oid relid;
+ Relation conflictrel;
+ int attno;
+ int options = HEAP_INSERT_NO_LOGICAL;
+ char *relname;
+ char *origin = NULL;
+ char *remote_origin = NULL;
+ HeapTuple tup;
+
+ /* If conflict history is not enabled for the subscription just return. */
+ relname = get_subscription_conflictrel(MyLogicalRepWorker->subid, &nspid);
+ if (relname == NULL)
+ return;
+
+ /* TODO: proper error code */
+ relid = get_relname_relid(relname, nspid);
+ if (!OidIsValid(relid))
+ elog(ERROR, "conflict log history table does not exists");
+ conflictrel = table_open(relid, RowExclusiveLock);
+ if (conflictrel == NULL)
+ elog(ERROR, "could not open conflict log history table");
+
+
+ /* Initialize values and nulls arrays */
+ memset(values, 0, sizeof(Datum) * MAX_CONFLICT_ATTR_NUM);
+ memset(nulls, 0, sizeof(bool) * MAX_CONFLICT_ATTR_NUM);
+
+ /* Populate the values and nulls arrays */
+ attno = 0;
+ values[attno] = ObjectIdGetDatum(RelationGetRelid(rel));
+ attno++;
+
+ values[attno] = TransactionIdGetDatum(local_xid);
+ attno++;
+
+ values[attno] = TransactionIdGetDatum(remote_xid);
+ attno++;
+
+ values[attno] = LSNGetDatum(remote_final_lsn);
+ attno++;
+
+ values[attno] = TimestampTzGetDatum(local_ts);
+ attno++;
+
+ values[attno] = TimestampTzGetDatum(remote_commit_ts);
+ attno++;
+
+ values[attno] =
+ CStringGetTextDatum(get_namespace_name(RelationGetNamespace(rel)));
+ attno++;
+
+ values[attno] = CStringGetTextDatum(RelationGetRelationName(rel));
+ attno++;
+
+ values[attno] = CStringGetTextDatum(ConflictTypeNames[conflict_type]);
+ attno++;
+
+ if (origin_id != InvalidRepOriginId)
+ replorigin_by_oid(origin_id, true, &origin);
+
+ if (origin != NULL)
+ values[attno] = CStringGetTextDatum(origin);
+ else
+ nulls[attno] = true;
+ attno++;
+
+ if (replorigin_session_origin != InvalidRepOriginId)
+ replorigin_by_oid(replorigin_session_origin, true, &remote_origin);
+
+ if (remote_origin != NULL)
+ values[attno] = CStringGetTextDatum(remote_origin);
+ else
+ nulls[attno] = true;
+ attno++;
+
+ if (searchslot != NULL)
+ values[attno] = TupleTableSlotToJsonDatum(searchslot);
+ else
+ nulls[attno] = true;
+ attno++;
+
+ if (localslot != NULL)
+ values[attno] = TupleTableSlotToJsonDatum(localslot);
+ else
+ nulls[attno] = true;
+ attno++;
+
+ if (remoteslot != NULL)
+ values[attno] = TupleTableSlotToJsonDatum(remoteslot);
+ else
+ nulls[attno] = true;
+
+ tup = heap_form_tuple(RelationGetDescr(conflictrel), values, nulls);
+ heap_insert(conflictrel, tup, GetCurrentCommandId(true), options, NULL);
+ table_close(conflictrel, RowExclusiveLock);
+
+ pfree(relname);
+}
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 93970c6af29..e6c02685ec9 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -482,7 +482,9 @@ static bool MySubscriptionValid = false;
static List *on_commit_wakeup_workers_subids = NIL;
bool in_remote_transaction = false;
-static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+TransactionId remote_xid = InvalidTransactionId;
+TimestampTz remote_commit_ts = 0;
/* fields valid only when processing streamed transaction */
static bool in_streamed_transaction = false;
@@ -1219,6 +1221,8 @@ apply_handle_begin(StringInfo s)
set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
remote_final_lsn = begin_data.final_lsn;
+ remote_commit_ts = begin_data.committime;
+ remote_xid = begin_data.xid;
maybe_start_skipping_changes(begin_data.final_lsn);
@@ -1745,6 +1749,10 @@ apply_handle_stream_start(StringInfo s)
/* extract XID of the top-level transaction */
stream_xid = logicalrep_read_stream_start(s, &first_segment);
+ remote_xid = stream_xid;
+ remote_final_lsn = InvalidXLogRecPtr;
+ remote_commit_ts = 0;
+
if (!TransactionIdIsValid(stream_xid))
ereport(ERROR,
(errcode(ERRCODE_PROTOCOL_VIOLATION),
diff --git a/src/backend/utils/cache/lsyscache.c b/src/backend/utils/cache/lsyscache.c
index fa7cd7e06a7..34fe347ebb7 100644
--- a/src/backend/utils/cache/lsyscache.c
+++ b/src/backend/utils/cache/lsyscache.c
@@ -3881,3 +3881,43 @@ get_subscription_name(Oid subid, bool missing_ok)
return subname;
}
+
+/*
+ * get_subscription_conflictrel
+ *
+ * Get conflict relation name and namespace id from subscription.
+ */
+char *
+get_subscription_conflictrel(Oid subid, Oid *nspid)
+{
+ HeapTuple tup;
+ Datum datum;
+ bool isnull;
+ char *relname;
+ Form_pg_subscription subform;
+
+ tup = SearchSysCache1(SUBSCRIPTIONOID, ObjectIdGetDatum(subid));
+
+ if (!HeapTupleIsValid(tup))
+ return NULL;
+
+ subform = (Form_pg_subscription) GETSTRUCT(tup);
+
+ /* Get conflict table name */
+ datum = SysCacheGetAttr(SUBSCRIPTIONOID,
+ tup,
+ Anum_pg_subscription_subconflicttable,
+ &isnull);
+ if (isnull)
+ {
+ ReleaseSysCache(tup);
+ return NULL;
+ }
+
+ *nspid = subform->subconflictnspid;
+ relname = pstrdup(TextDatumGetCString(datum));
+
+ ReleaseSysCache(tup);
+
+ return relname;
+}
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index 55cb9b1eefa..ec31e2b1d56 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -80,6 +80,8 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
bool subretaindeadtuples; /* True if dead tuples useful for
* conflict detection are retained */
+ Oid subconflictnspid; /* Namespace Oid in which the conflict history
+ * table is created. */
int32 submaxretention; /* The maximum duration (in milliseconds)
* for which information useful for
@@ -105,6 +107,9 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
/* Only publish data originating from the specified origin */
text suborigin BKI_DEFAULT(LOGICALREP_ORIGIN_ANY);
+
+ /* conflict log history table name if valid */
+ text subconflicttable;
#endif
} FormData_pg_subscription;
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index c8fbf9e51b8..adc46e79286 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -62,6 +62,7 @@ typedef enum
} ConflictType;
#define CONFLICT_NUM_TYPES (CT_MULTIPLE_UNIQUE_CONFLICTS + 1)
+#define MAX_CONFLICT_ATTR_NUM 15
/*
* Information for the existing local row that caused the conflict.
@@ -89,4 +90,5 @@ extern void ReportApplyConflict(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *remoteslot,
List *conflicttuples);
extern void InitConflictIndexes(ResultRelInfo *relInfo);
+
#endif
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index f081619f151..314ac5dc746 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -256,6 +256,10 @@ extern PGDLLIMPORT bool InitializingApplyWorker;
extern PGDLLIMPORT List *table_states_not_ready;
+extern XLogRecPtr remote_final_lsn;
+extern TimestampTz remote_commit_ts;
+extern TransactionId remote_xid;
+
extern void logicalrep_worker_attach(int slot);
extern LogicalRepWorker *logicalrep_worker_find(LogicalRepWorkerType wtype,
Oid subid, Oid relid,
diff --git a/src/include/utils/lsyscache.h b/src/include/utils/lsyscache.h
index 50fb149e9ac..dc6df5843a4 100644
--- a/src/include/utils/lsyscache.h
+++ b/src/include/utils/lsyscache.h
@@ -210,6 +210,7 @@ extern Oid get_publication_oid(const char *pubname, bool missing_ok);
extern char *get_publication_name(Oid pubid, bool missing_ok);
extern Oid get_subscription_oid(const char *subname, bool missing_ok);
extern char *get_subscription_name(Oid subid, bool missing_ok);
+extern char *get_subscription_conflictrel(Oid subid, Oid *nspid);
#define type_is_array(typid) (get_element_type(typid) != InvalidOid)
/* type_is_array_domain accepts both plain arrays and domains over arrays */
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index 327d1e7731f..72eea5e1601 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -517,6 +517,85 @@ COMMIT;
-- ok, owning it is enough for this stuff
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG HISTORY TABLE TESTS
+--
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+-- fail - conflict_log_table specified when table already exists
+CREATE TABLE public.regress_conflict_log_temp (id int);
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log_temp');
+ERROR: cannot create conflict history table "public.regress_conflict_log_temp" because a table with that name already exists
+HINT: Use a different name for the conflict history table or drop the existing table.
+DROP TABLE public.regress_conflict_log_temp;
+-- ok - conflict_log_table creation with CREATE SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- check metadata in pg_subscription
+SELECT subname, subconflicttable, subconflictnspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname | subconflicttable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test1 | regress_conflict_log1 | t
+(1 row)
+
+-- check if the table exists and has the correct schema (15 columns)
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attnum > 0;
+ count
+-------
+ 14
+(1 row)
+
+-- check a specific column type (e.g., key_tuple should be JSON)
+SELECT format_type(atttypid, atttypmod) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attname = 'key_tuple';
+ format_type
+-------------
+ json
+(1 row)
+
+-- ok - adding conflict_log_table with ALTER SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log2');
+-- check metadata after ALTER
+SELECT subname, subconflicttable, subconflictnspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflicttable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test2 | regress_conflict_log2 | t
+(1 row)
+
+-- ok - dropping subscription also drops the log table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the table was dropped
+SELECT to_regclass('public.regress_conflict_log1');
+ to_regclass
+-------------
+
+(1 row)
+
+-- ok - dropping subscription when the log table was manually dropped first
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+DROP TABLE public.regress_conflict_log1;
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+NOTICE: table "regress_conflict_log1" does not exist, skipping
+-- should return NULL, meaning the subscription was dropped successfully
+SELECT subname FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname
+---------
+(0 rows)
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
RESET SESSION AUTHORIZATION;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index ef0c298d2df..02afbf5c213 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -365,6 +365,61 @@ COMMIT;
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG HISTORY TABLE TESTS
+--
+
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+
+-- fail - conflict_log_table specified when table already exists
+CREATE TABLE public.regress_conflict_log_temp (id int);
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log_temp');
+DROP TABLE public.regress_conflict_log_temp;
+
+-- ok - conflict_log_table creation with CREATE SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+
+-- check metadata in pg_subscription
+SELECT subname, subconflicttable, subconflictnspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- check if the table exists and has the correct schema (15 columns)
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attnum > 0;
+
+-- check a specific column type (e.g., key_tuple should be JSON)
+SELECT format_type(atttypid, atttypmod) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attname = 'key_tuple';
+
+-- ok - adding conflict_log_table with ALTER SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log2');
+
+-- check metadata after ALTER
+SELECT subname, subconflicttable, subconflictnspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- ok - dropping subscription also drops the log table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the table was dropped
+SELECT to_regclass('public.regress_conflict_log1');
+
+-- ok - dropping subscription when the log table was manually dropped first
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+DROP TABLE public.regress_conflict_log1;
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the subscription was dropped successfully
+SELECT subname FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+
RESET SESSION AUTHORIZATION;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
--
2.49.0
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-11-12 09:44 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: shveta malik @ 2025-11-12 09:44 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Wed, Nov 12, 2025 at 2:40 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Wed, Nov 12, 2025 at 12:21 PM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > On Fri, Sep 26, 2025 at 4:42 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
>
> > I agree that marking tables with a flag to easily exclude them during
> > publishing would be cleaner. In the current patch, for an ALL-TABLES
> > publication, we scan pg_subscription for each table in pg_class to
> > check its subconflicttable and decide whether to ignore it. But since
> > this only happens during create/alter subscription and refresh
> > publication, the overhead should be acceptable.
>
> Thanks for your opinion.
>
> > Introducing a ‘NON_PUBLISHABLE_TABLE’ option would be a good
> > enhancement but since we already have the EXCEPT list built in a
> > separate thread, that might be sufficient for now. IMO, such
> > conflict-tables should be marked internally (for example, with a
> > ‘non_publishable’ or ‘conflict_log_table’ flag) so they can be easily
> > identified within the system, without requiring users to explicitly
> > specify them in EXCEPT or as NON_PUBLISHABLE_TABLE. I would like to
> > see what others think on this.
> > For the time being, the current implementation looks fine, considering
> > it runs only during a few publication-related DDL operations.
>
> +1
>
> Here is the rebased patch, changes apart from rebasing it
> 1) Dropped the conflict history table during drop subscription
> 2) Added test cases for testing the conflict history table behavior
> with CREATE/ALTER/DROP subscription
Thanks.
> TODO:
> 1) Need more thoughts on the table schema whether we need to capture
> more items or shall we drop some fields if we think those are not
> necessary.
Yes, this needs some more thoughts. I will review.
I feel since design is somewhat agreed upon, we may handle
code-correction/completion. I have not looked at the rebased patch
yet, but here are a few comments based on old-version.
Few observations related to publication.
------------------------------
(In the below comments, clt/CLT implies Conflict Log Table)
1)
'select pg_relation_is_publishable(clt)' returns true for conflict-log table.
2)
'\d+ clt' shows all-tables publication name. I feel we should not
show that for clt.
3)
I am able to create a publication for clt table, should it be allowed?
create subscription sub1 connection '...' publication pub1
WITH(conflict_log_table='clt');
create publication pub3 for table clt;
4)
Is there a reason we have not made '!IsConflictHistoryRelid' check as
part of is_publishable_class() itself? If we do so, other code-logics
will also get clt as non-publishable always (and will solve a few of
the above issues I think). IIUC, there is no place where we want to
mark CLT as publishable or is there any?
5) Also, I feel we can add some documentation now to help others to
understand/review the patch better without going through the long
thread.
Few observations related to conflict-logging:
------------------------------
1)
I found that for the conflicts which ultimately result in Error, we do
not insert any conflict-record in clt.
a)
Example: insert_exists, update_Exists
create table tab1 (i int primary key, j int);
sub: insert into tab1 values(30,10);
pub: insert into tab1 values(30,10);
ERROR: conflict detected on relation "public.tab1": conflict=insert_exists
No record in clt.
sub:
<some pre-data needed>
update tab1 set i=40 where i = 30;
pub: update tab1 set i=40 where i = 20;
ERROR: conflict detected on relation "public.tab1": conflict=update_exists
No record in clt.
b)
Another question related to this is, since these conflicts (which
results in error) keep on happening until user resolves these or skips
these or 'disable_on_error' is set. Then are we going to insert these
multiple times? We do count these in 'confl_insert_exists' and
'confl_update_exists' everytime, so it makes sense to log those each
time in clt as well. Thoughts?
2)
Conflicts where row on sub is missing, local_ts incorrectly inserted.
It is '2000-01-01 05:30:00+05:30'. Should it be Null or something
indicating that it is not applicable for this conflict-type?
Example: delete_missing, update_missing
pub:
insert into tab1 values(10,10);
insert into tab1 values(20,10);
sub: delete from tab1 where i=10;
pub: delete from tab1 where i=10;
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-11-13 09:09 shveta malik <shveta.malik@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: shveta malik @ 2025-11-13 09:09 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Wed, Nov 12, 2025 at 3:14 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> On Wed, Nov 12, 2025 at 2:40 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Wed, Nov 12, 2025 at 12:21 PM shveta malik <shveta.malik@gmail.com> wrote:
> > >
> > > On Fri, Sep 26, 2025 at 4:42 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > >
> >
> > > I agree that marking tables with a flag to easily exclude them during
> > > publishing would be cleaner. In the current patch, for an ALL-TABLES
> > > publication, we scan pg_subscription for each table in pg_class to
> > > check its subconflicttable and decide whether to ignore it. But since
> > > this only happens during create/alter subscription and refresh
> > > publication, the overhead should be acceptable.
> >
> > Thanks for your opinion.
> >
> > > Introducing a ‘NON_PUBLISHABLE_TABLE’ option would be a good
> > > enhancement but since we already have the EXCEPT list built in a
> > > separate thread, that might be sufficient for now. IMO, such
> > > conflict-tables should be marked internally (for example, with a
> > > ‘non_publishable’ or ‘conflict_log_table’ flag) so they can be easily
> > > identified within the system, without requiring users to explicitly
> > > specify them in EXCEPT or as NON_PUBLISHABLE_TABLE. I would like to
> > > see what others think on this.
> > > For the time being, the current implementation looks fine, considering
> > > it runs only during a few publication-related DDL operations.
> >
> > +1
> >
> > Here is the rebased patch, changes apart from rebasing it
> > 1) Dropped the conflict history table during drop subscription
> > 2) Added test cases for testing the conflict history table behavior
> > with CREATE/ALTER/DROP subscription
>
> Thanks.
>
> > TODO:
> > 1) Need more thoughts on the table schema whether we need to capture
> > more items or shall we drop some fields if we think those are not
> > necessary.
>
> Yes, this needs some more thoughts. I will review.
>
> I feel since design is somewhat agreed upon, we may handle
> code-correction/completion. I have not looked at the rebased patch
> yet, but here are a few comments based on old-version.
>
> Few observations related to publication.
> ------------------------------
>
> (In the below comments, clt/CLT implies Conflict Log Table)
>
> 1)
> 'select pg_relation_is_publishable(clt)' returns true for conflict-log table.
>
> 2)
> '\d+ clt' shows all-tables publication name. I feel we should not
> show that for clt.
>
> 3)
> I am able to create a publication for clt table, should it be allowed?
>
> create subscription sub1 connection '...' publication pub1
> WITH(conflict_log_table='clt');
> create publication pub3 for table clt;
>
> 4)
> Is there a reason we have not made '!IsConflictHistoryRelid' check as
> part of is_publishable_class() itself? If we do so, other code-logics
> will also get clt as non-publishable always (and will solve a few of
> the above issues I think). IIUC, there is no place where we want to
> mark CLT as publishable or is there any?
>
> 5) Also, I feel we can add some documentation now to help others to
> understand/review the patch better without going through the long
> thread.
>
>
> Few observations related to conflict-logging:
> ------------------------------
> 1)
> I found that for the conflicts which ultimately result in Error, we do
> not insert any conflict-record in clt.
>
> a)
> Example: insert_exists, update_Exists
> create table tab1 (i int primary key, j int);
> sub: insert into tab1 values(30,10);
> pub: insert into tab1 values(30,10);
> ERROR: conflict detected on relation "public.tab1": conflict=insert_exists
> No record in clt.
>
> sub:
> <some pre-data needed>
> update tab1 set i=40 where i = 30;
> pub: update tab1 set i=40 where i = 20;
> ERROR: conflict detected on relation "public.tab1": conflict=update_exists
> No record in clt.
>
> b)
> Another question related to this is, since these conflicts (which
> results in error) keep on happening until user resolves these or skips
> these or 'disable_on_error' is set. Then are we going to insert these
> multiple times? We do count these in 'confl_insert_exists' and
> 'confl_update_exists' everytime, so it makes sense to log those each
> time in clt as well. Thoughts?
>
> 2)
> Conflicts where row on sub is missing, local_ts incorrectly inserted.
> It is '2000-01-01 05:30:00+05:30'. Should it be Null or something
> indicating that it is not applicable for this conflict-type?
>
> Example: delete_missing, update_missing
> pub:
> insert into tab1 values(10,10);
> insert into tab1 values(20,10);
> sub: delete from tab1 where i=10;
> pub: delete from tab1 where i=10;
>
3)
We also need to think how we are going to display the info in case of
multiple_unique_conflicts as there could be multiple local and remote
tuples conflicting for one single operation. Example:
create table conf_tab (a int primary key, b int unique, c int unique);
sub: insert into conf_tab values (2,2,2), (3,3,3), (4,4,4);
pub: insert into conf_tab values (2,3,4);
ERROR: conflict detected on relation "public.conf_tab":
conflict=multiple_unique_conflicts
DETAIL: Key already exists in unique index "conf_tab_pkey", modified
locally in transaction 874 at 2025-11-12 14:35:13.452143+05:30.
Key (a)=(2); existing local row (2, 2, 2); remote row (2, 3, 4).
Key already exists in unique index "conf_tab_b_key", modified locally
in transaction 874 at 2025-11-12 14:35:13.452143+05:30.
Key (b)=(3); existing local row (3, 3, 3); remote row (2, 3, 4).
Key already exists in unique index "conf_tab_c_key", modified locally
in transaction 874 at 2025-11-12 14:35:13.452143+05:30.
Key (c)=(4); existing local row (4, 4, 4); remote row (2, 3, 4).
CONTEXT: processing remote data for replication origin "pg_16392"
during message type "INSERT" for replication target relation
"public.conf_tab" in transaction 781, finished at 0/017FDDA0
Currently in clt, we have singular terms such as 'key_tuple',
'local_tuple', 'remote_tuple'. Shall we have multiple rows inserted?
But it does not look reasonable to have multiple rows inserted for a
single conflict raised. I will think more about this.
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-11-13 15:47 Dilip Kumar <dilipbalaut@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 3 replies; 912+ messages in thread
From: Dilip Kumar @ 2025-11-13 15:47 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, Nov 13, 2025 at 2:39 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> > Few observations related to publication.
> > ------------------------------
Thanks Shveta, for testing and sharing your thoughts. IMHO for
conflict log tables it should be good enough if we restrict it when
ALL TABLE options are used, I don't think we need to put extra effort
to completely restrict it even if users want to explicitly list it
into the publication.
> >
> > (In the below comments, clt/CLT implies Conflict Log Table)
> >
> > 1)
> > 'select pg_relation_is_publishable(clt)' returns true for conflict-log table.
This function is used while publishing every single change and I don't
think we want to add a cost to check each subscription to identify
whether the table is listed as CLT.
> > 2)
> > '\d+ clt' shows all-tables publication name. I feel we should not
> > show that for clt.
I think we should fix this.
> > 3)
> > I am able to create a publication for clt table, should it be allowed?
I believe we should not do any specific handling to restrict this but
I am open for the opinions.
> > create subscription sub1 connection '...' publication pub1
> > WITH(conflict_log_table='clt');
> > create publication pub3 for table clt;
> >
> > 4)
> > Is there a reason we have not made '!IsConflictHistoryRelid' check as
> > part of is_publishable_class() itself? If we do so, other code-logics
> > will also get clt as non-publishable always (and will solve a few of
> > the above issues I think). IIUC, there is no place where we want to
> > mark CLT as publishable or is there any?
IMHO the main reason is performance.
> > 5) Also, I feel we can add some documentation now to help others to
> > understand/review the patch better without going through the long
> > thread.
Make sense, I will do that in the next version.
> >
> > Few observations related to conflict-logging:
> > ------------------------------
> > 1)
> > I found that for the conflicts which ultimately result in Error, we do
> > not insert any conflict-record in clt.
> >
> > a)
> > Example: insert_exists, update_Exists
> > create table tab1 (i int primary key, j int);
> > sub: insert into tab1 values(30,10);
> > pub: insert into tab1 values(30,10);
> > ERROR: conflict detected on relation "public.tab1": conflict=insert_exists
> > No record in clt.
> >
> > sub:
> > <some pre-data needed>
> > update tab1 set i=40 where i = 30;
> > pub: update tab1 set i=40 where i = 20;
> > ERROR: conflict detected on relation "public.tab1": conflict=update_exists
> > No record in clt.
Yeah that interesting need to put thought on how to commit this record
when an outer transaction is aborted as we do not have autonomous
transactions which are generally used for this kind of logging. But
we can explore more options like inserting into conflict log tables
outside the outer transaction.
> > b)
> > Another question related to this is, since these conflicts (which
> > results in error) keep on happening until user resolves these or skips
> > these or 'disable_on_error' is set. Then are we going to insert these
> > multiple times? We do count these in 'confl_insert_exists' and
> > 'confl_update_exists' everytime, so it makes sense to log those each
> > time in clt as well. Thoughts?
I think it make sense to insert every time we see the conflict, but it
would be good to have opinion from others as well.
> > 2)
> > Conflicts where row on sub is missing, local_ts incorrectly inserted.
> > It is '2000-01-01 05:30:00+05:30'. Should it be Null or something
> > indicating that it is not applicable for this conflict-type?
> >
> > Example: delete_missing, update_missing
> > pub:
> > insert into tab1 values(10,10);
> > insert into tab1 values(20,10);
> > sub: delete from tab1 where i=10;
> > pub: delete from tab1 where i=10;
Sure I will test this.
>
> 3)
> We also need to think how we are going to display the info in case of
> multiple_unique_conflicts as there could be multiple local and remote
> tuples conflicting for one single operation. Example:
>
> create table conf_tab (a int primary key, b int unique, c int unique);
>
> sub: insert into conf_tab values (2,2,2), (3,3,3), (4,4,4);
>
> pub: insert into conf_tab values (2,3,4);
>
> ERROR: conflict detected on relation "public.conf_tab":
> conflict=multiple_unique_conflicts
> DETAIL: Key already exists in unique index "conf_tab_pkey", modified
> locally in transaction 874 at 2025-11-12 14:35:13.452143+05:30.
> Key (a)=(2); existing local row (2, 2, 2); remote row (2, 3, 4).
> Key already exists in unique index "conf_tab_b_key", modified locally
> in transaction 874 at 2025-11-12 14:35:13.452143+05:30.
> Key (b)=(3); existing local row (3, 3, 3); remote row (2, 3, 4).
> Key already exists in unique index "conf_tab_c_key", modified locally
> in transaction 874 at 2025-11-12 14:35:13.452143+05:30.
> Key (c)=(4); existing local row (4, 4, 4); remote row (2, 3, 4).
> CONTEXT: processing remote data for replication origin "pg_16392"
> during message type "INSERT" for replication target relation
> "public.conf_tab" in transaction 781, finished at 0/017FDDA0
>
> Currently in clt, we have singular terms such as 'key_tuple',
> 'local_tuple', 'remote_tuple'. Shall we have multiple rows inserted?
> But it does not look reasonable to have multiple rows inserted for a
> single conflict raised. I will think more about this.
Currently I am inserting multiple records in the conflict history
table, the same as each tuple is logged, but couldn't find any better
way for this. Another option is to use an array of tuples instead of a
single tuple but not sure this might make things more complicated to
process by any external tool. But you are right, this needs more
discussion.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-11-17 06:24 Dilip Kumar <dilipbalaut@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
2 siblings, 4 replies; 912+ messages in thread
From: Dilip Kumar @ 2025-11-17 06:24 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, Nov 13, 2025 at 9:17 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Thu, Nov 13, 2025 at 2:39 PM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > > Few observations related to publication.
> > > ------------------------------
>
> Thanks Shveta, for testing and sharing your thoughts. IMHO for
> conflict log tables it should be good enough if we restrict it when
> ALL TABLE options are used, I don't think we need to put extra effort
> to completely restrict it even if users want to explicitly list it
> into the publication.
>
> > >
> > > (In the below comments, clt/CLT implies Conflict Log Table)
> > >
> > > 1)
> > > 'select pg_relation_is_publishable(clt)' returns true for conflict-log table.
After putting more thought I have changed this to return false for
clt, as this is just an exposed function not called by pgoutput layer.
> > > 2)
> > > '\d+ clt' shows all-tables publication name. I feel we should not
> > > show that for clt.
>
Fixed
>
> > > 3)
> > > I am able to create a publication for clt table, should it be allowed?
>
> I believe we should not do any specific handling to restrict this but
> I am open for the opinions.
Restricting this as well, lets see what others think.
>
> > > 5) Also, I feel we can add some documentation now to help others to
> > > understand/review the patch better without going through the long
> > > thread.
>
> Make sense, I will do that in the next version.
Done that but not compiled the docs as I don't currently have the
setup so added as WIP patch.
> > > 2)
> > > Conflicts where row on sub is missing, local_ts incorrectly inserted.
> > > It is '2000-01-01 05:30:00+05:30'. Should it be Null or something
> > > indicating that it is not applicable for this conflict-type?
> > >
> > > Example: delete_missing, update_missing
> > > pub:
> > > insert into tab1 values(10,10);
> > > insert into tab1 values(20,10);
> > > sub: delete from tab1 where i=10;
> > > pub: delete from tab1 where i=10;
>
> Sure I will test this.
I have fixed this.
--
Regards,
Dilip Kumar
Google
Attachments:
[application/octet-stream] v4-0002-Don-t-add-conflict-history-tables-to-publishable-.patch (4.6K, ../../CAFiTN-uWq0jrcTaOiGVN4mxJa6iFi=2P98=oYUpgh+8gS2oziw@mail.gmail.com/2-v4-0002-Don-t-add-conflict-history-tables-to-publishable-.patch)
download | inline diff:
From 29823a029fb9b74e3b5414f7bbccd29c94ba0dd7 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 12 Nov 2025 14:30:39 +0530
Subject: [PATCH v4 2/3] Don't add conflict history tables to publishable
relation
When all table option is used with publication don't publish the
conflict history tables.
---
src/backend/catalog/pg_publication.c | 16 ++++++++--
src/backend/commands/subscriptioncmds.c | 40 +++++++++++++++++++++++++
src/include/commands/subscriptioncmds.h | 2 ++
3 files changed, 56 insertions(+), 2 deletions(-)
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index ac2f4ee3561..cab1776e78c 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -31,6 +31,7 @@
#include "catalog/pg_publication_rel.h"
#include "catalog/pg_type.h"
#include "commands/publicationcmds.h"
+#include "commands/subscriptioncmds.h"
#include "funcapi.h"
#include "utils/array.h"
#include "utils/builtins.h"
@@ -72,6 +73,14 @@ check_publication_add_relation(Relation targetrel)
RelationGetRelationName(targetrel)),
errdetail("This operation is not supported for system tables.")));
+ /* Can't be created as conflict log table */
+ if (IsConflictLogRelid(RelationGetRelid(targetrel)))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot add relation \"%s\" to publication",
+ RelationGetRelationName(targetrel)),
+ errdetail("This operation is not supported for conflict log tables.")));
+
/* UNLOGGED and TEMP relations cannot be part of publication. */
if (targetrel->rd_rel->relpersistence == RELPERSISTENCE_TEMP)
ereport(ERROR,
@@ -153,7 +162,7 @@ is_publishable_relation(Relation rel)
}
/*
- * SQL-callable variant of the above
+ * SQL-callable variant of the above and this should not be a conflict log rel
*
* This returns null when the relation does not exist. This is intended to be
* used for example in psql to avoid gratuitous errors when there are
@@ -169,7 +178,8 @@ pg_relation_is_publishable(PG_FUNCTION_ARGS)
tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
if (!HeapTupleIsValid(tuple))
PG_RETURN_NULL();
- result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple));
+ result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple)) &&
+ !IsConflictLogRelid(relid);
ReleaseSysCache(tuple);
PG_RETURN_BOOL(result);
}
@@ -890,7 +900,9 @@ GetAllPublicationRelations(char relkind, bool pubviaroot)
Form_pg_class relForm = (Form_pg_class) GETSTRUCT(tuple);
Oid relid = relForm->oid;
+ /* conflict history tables are not published. */
if (is_publishable_class(relid, relForm) &&
+ !IsConflictLogRelid(relid) &&
!(relForm->relispartition && pubviaroot))
result = lappend_oid(result, relid);
}
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index e48728dec07..7dc04917aa3 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -15,6 +15,7 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/htup_details.h"
#include "access/table.h"
#include "access/twophase.h"
@@ -3357,3 +3358,42 @@ DropConflictLogTable(Oid namespaceId, char *conflictrel)
pfree(querybuf.data);
}
+
+/*
+ * Is relation used as a conflict log table
+ *
+ * Scan all the subscription and check whether the relation is used as
+ * conflict log table.
+ */
+bool
+IsConflictLogRelid(Oid relid)
+{
+ Relation rel;
+ TableScanDesc scan;
+ HeapTuple tup;
+ bool found = false;
+
+ rel = table_open(SubscriptionRelationId, AccessShareLock);
+ scan = table_beginscan_catalog(rel, 0, NULL);
+
+ while (HeapTupleIsValid(tup = heap_getnext(scan, ForwardScanDirection)))
+ {
+ Form_pg_subscription subform = (Form_pg_subscription) GETSTRUCT(tup);
+ Oid nspid;
+ char *relname;
+
+ relname = get_subscription_conflictrel(subform->oid, &nspid);
+ if (relname == NULL)
+ continue;
+ if (relid == get_relname_relid(relname, nspid))
+ {
+ found = true;
+ break;
+ }
+ }
+
+ table_endscan(scan);
+ table_close(rel, AccessShareLock);
+
+ return found;
+}
diff --git a/src/include/commands/subscriptioncmds.h b/src/include/commands/subscriptioncmds.h
index fb4e26a51a4..b5e9cbf8bfe 100644
--- a/src/include/commands/subscriptioncmds.h
+++ b/src/include/commands/subscriptioncmds.h
@@ -36,4 +36,6 @@ extern void CheckSubDeadTupleRetention(bool check_guc, bool sub_disabled,
bool retention_active,
bool max_retention_set);
+extern bool IsConflictLogRelid(Oid relid);
+
#endif /* SUBSCRIPTIONCMDS_H */
--
2.49.0
[application/octet-stream] v4-0003-WIP-conflict-log-table-docs.patch (10.7K, ../../CAFiTN-uWq0jrcTaOiGVN4mxJa6iFi=2P98=oYUpgh+8gS2oziw@mail.gmail.com/3-v4-0003-WIP-conflict-log-table-docs.patch)
download | inline diff:
From d8a5e11dcc63406c86584cf08abd4f5a3c314605 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Mon, 17 Nov 2025 11:44:55 +0530
Subject: [PATCH v4 3/3] WIP: conflict log table docs
---
doc/src/sgml/logical-replication.sgml | 126 +++++++++++++++++++++-
doc/src/sgml/ref/alter_subscription.sgml | 11 +-
doc/src/sgml/ref/create_subscription.sgml | 19 ++++
3 files changed, 152 insertions(+), 4 deletions(-)
diff --git a/doc/src/sgml/logical-replication.sgml b/doc/src/sgml/logical-replication.sgml
index d64ed9dc36b..951a9bc0e3c 100644
--- a/doc/src/sgml/logical-replication.sgml
+++ b/doc/src/sgml/logical-replication.sgml
@@ -248,7 +248,9 @@
The subscription is added using <link linkend="sql-createsubscription"><command>CREATE SUBSCRIPTION</command></link> and
can be stopped/resumed at any time using the
<link linkend="sql-altersubscription"><command>ALTER SUBSCRIPTION</command></link> command and removed using
- <link linkend="sql-dropsubscription"><command>DROP SUBSCRIPTION</command></link>.
+ <link linkend="sql-dropsubscription"><command>DROP SUBSCRIPTION</command></link>. If a
+ <literal>conflict_log_table</literal> was specified for the subscription, that internally
+ managed table is automatically dropped along with the subscription.
</para>
<para>
@@ -284,6 +286,18 @@
option of <command>CREATE SUBSCRIPTION</command> for details.
</para>
+ <para>
+ Conflicts that occur during replication are typically logged as plain text
+ in the server log, which can be difficult for automated monitoring and
+ analysis. The <command>CREATE SUBSCRIPTION</command> command provides the
+ <link linkend="sql-createsubscription-params-with-conflict-log-table"><literal>conflict_log_table</literal></link>
+ option to specify a table name where detailed conflict information
+ is recorded in a structured, queryable format, significantly improving
+ post-mortem analysis and operational visibility of the replication setup.
+ This table is created and managed internally by the system and is owned
+ by the subscription owner.
+ </para>
+
<sect2 id="logical-replication-subscription-slot">
<title>Replication Slot Management</title>
@@ -1762,7 +1776,9 @@ Publications:
<para>
Additional logging is triggered, and the conflict statistics are collected (displayed in the
<link linkend="monitoring-pg-stat-subscription-stats"><structname>pg_stat_subscription_stats</structname></link> view)
- in the following <firstterm>conflict</firstterm> cases:
+ in the following <firstterm>conflict</firstterm> cases (If the subscription was created with the
+ <literal>conflict_log_table</literal> option, detailed conflict information is also inserted
+ into the specified table, providing a structured record of all conflicts).
<variablelist>
<varlistentry id="conflict-insert-exists" xreflabel="insert_exists">
<term><literal>insert_exists</literal></term>
@@ -1871,6 +1887,104 @@ Publications:
log.
</para>
+ <para>
+ When the <literal>conflict_log_table</literal> option is enabled, the system automatically creates
+ a new table with a predefined schema to log conflict details. This table is created in the
+ specified schema, is **owned by the subscription owner**, and logs system fields. The schema of this table is
+ detailed in <xref linkend="logical-replication-conflict-log-schema"/>.
+ </para>
+
+ <table id="logical-replication-conflict-log-schema">
+ <title>Conflict Log History Table Schema</title>
+ <tgroup cols="3">
+ <thead>
+ <row>
+ <entry>Column</entry>
+ <entry>Type</entry>
+ <entry>Description</entry>
+ </row>
+ </thead>
+ <tbody>
+ <row>
+ <entry><literal>relid</literal></entry>
+ <entry><type>oid</type></entry>
+ <entry>The OID of the local table where the conflict occurred.</entry>
+ </row>
+ <row>
+ <entry><literal>local_xid</literal></entry>
+ <entry><type>xid</type></entry>
+ <entry>The local transaction ID involved in the conflict (NULL if local tuple missing).</entry>
+ </row>
+ <row>
+ <entry><literal>remote_xid</literal></entry>
+ <entry><type>xid</type></entry>
+ <entry>The remote transaction ID that caused the conflict.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_commit_lsn</literal></entry>
+ <entry><type>pg_lsn</type></entry>
+ <entry>The final LSN of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>local_commit_ts</literal></entry>
+ <entry><type>timestamptz</type></entry>
+ <entry>The local commit timestamp of the local conflicting row (NULL if local tuple missing).</entry>
+ </row>
+ <row>
+ <entry><literal>remote_commit_ts</literal></entry>
+ <entry><type>timestamptz</type></entry>
+ <entry>The remote commit timestamp of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>table_schema</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The schema name of the conflicting table.</entry>
+ </row>
+ <row>
+ <entry><literal>table_name</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The name of the conflicting table.</entry>
+ </row>
+ <row>
+ <entry><literal>conflict_type</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The type of conflict that occurred (e.g., <literal>insert_exists</literal>).</entry>
+ </row>
+ <row>
+ <entry><literal>local_origin</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The origin of the local transaction (if applicable).</entry>
+ </row>
+ <row>
+ <entry><literal>remote_origin</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The origin of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>key_tuple</literal></entry>
+ <entry><type>json</type></entry>
+ <entry>The JSON representation of the replica identity or primary key tuple involved.</entry>
+ </row>
+ <row>
+ <entry><literal>local_tuple</literal></entry>
+ <entry><type>json</type></entry>
+ <entry>The JSON representation of the local row before the conflict (NULL if missing).</entry>
+ </row>
+ <row>
+ <entry><literal>remote_tuple</literal></entry>
+ <entry><type>json</type></entry>
+ <entry>The JSON representation of the incoming remote row that caused the conflict.</entry>
+ </row>
+ </tbody>
+ </tgroup>
+ </table>
+
+ <para>
+ The conflicting row data, including the original local tuple and
+ the remote tuple, is stored in <type>JSON</type> columns (<literal>local_tuple</literal>
+ and <literal>remote_tuple</literal>) for flexible querying and analysis.
+ </para>
+
<para>
The log format for logical replication conflicts is as follows:
<synopsis>
@@ -2163,6 +2277,14 @@ CONTEXT: processing remote data for replication origin "pg_16395" during "INSER
key or replica identity defined for it.
</para>
</listitem>
+
+ <listitem>
+ <para>
+ The conflict log history table created using the <literal>conflict_log_table</literal>
+ option on a subscription is not published even if the publication is defined with
+ <literal>FOR ALL TABLES</literal>.
+ </para>
+ </listitem>
</itemizedlist>
</sect1>
diff --git a/doc/src/sgml/ref/alter_subscription.sgml b/doc/src/sgml/ref/alter_subscription.sgml
index 8ab3b7fbd37..e221fba8c57 100644
--- a/doc/src/sgml/ref/alter_subscription.sgml
+++ b/doc/src/sgml/ref/alter_subscription.sgml
@@ -265,8 +265,9 @@ ALTER SUBSCRIPTION <replaceable class="parameter">name</replaceable> RENAME TO <
<link linkend="sql-createsubscription-params-with-origin"><literal>origin</literal></link>,
<link linkend="sql-createsubscription-params-with-failover"><literal>failover</literal></link>,
<link linkend="sql-createsubscription-params-with-two-phase"><literal>two_phase</literal></link>,
- <link linkend="sql-createsubscription-params-with-retain-dead-tuples"><literal>retain_dead_tuples</literal></link>, and
- <link linkend="sql-createsubscription-params-with-max-retention-duration"><literal>max_retention_duration</literal></link>.
+ <link linkend="sql-createsubscription-params-with-retain-dead-tuples"><literal>retain_dead_tuples</literal></link>,
+ <link linkend="sql-createsubscription-params-with-max-retention-duration"><literal>max_retention_duration</literal></link> and,
+ <link linkend="sql-createsubscription-params-with-conflict-log-table"><literal>conflict_log_table</literal></link>.
Only a superuser can set <literal>password_required = false</literal>.
</para>
@@ -324,6 +325,12 @@ ALTER SUBSCRIPTION <replaceable class="parameter">name</replaceable> RENAME TO <
<quote><literal>pg_conflict_detection</literal></quote>, created to retain
dead tuples for conflict detection, will be dropped.
</para>
+
+ <para>
+ When altering the <link linkend="sql-createsubscription-params-with-conflict-log-table"><literal>conflict_log_table</literal></link>, the target table must not
+ exist in the specified schema; attempting to set the parameter to an
+ existing table name will result in an error.
+ </para>
</listitem>
</varlistentry>
diff --git a/doc/src/sgml/ref/create_subscription.sgml b/doc/src/sgml/ref/create_subscription.sgml
index ed82cf1809e..0923f2fc801 100644
--- a/doc/src/sgml/ref/create_subscription.sgml
+++ b/doc/src/sgml/ref/create_subscription.sgml
@@ -268,6 +268,25 @@ CREATE SUBSCRIPTION <replaceable class="parameter">subscription_name</replaceabl
</listitem>
</varlistentry>
+ <varlistentry id="sql-createsubscription-params-with-conflict-log-table">
+ <term><literal>conflict_log_table</literal> (<type>string</type>)</term>
+ <listitem>
+ <para>
+ Specifies the qualified table name where detailed logical replication
+ conflict information will be recorded. The table specified by this option
+ must not exist when the subscription is created; if it does, an error will
+ be raised. This table is automatically created by the system, and it is
+ owned by the subscription owner. The table's predefined schema includes
+ fields for transaction details, LSNs, and JSON columns for the conflicting
+ local and remote tuples etc.
+ </para>
+ <para>
+ If this option is used, the table is automatically dropped when the subscription
+ is dropped.
+ </para>
+ </listitem>
+ </varlistentry>
+
<varlistentry id="sql-createsubscription-params-with-streaming">
<term><literal>streaming</literal> (<type>enum</type>)</term>
<listitem>
--
2.49.0
[application/octet-stream] v4-0001-Add-configurable-conflict-log-history-table-for.patch (31.4K, ../../CAFiTN-uWq0jrcTaOiGVN4mxJa6iFi=2P98=oYUpgh+8gS2oziw@mail.gmail.com/4-v4-0001-Add-configurable-conflict-log-history-table-for.patch)
download | inline diff:
From 5ae39da28c210b9acd2d93e5444f6ed8a1e87acf Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 12 Nov 2025 10:43:19 +0530
Subject: [PATCH v4 1/3] Add configurable conflict log history table for
Logical Replication
This patch adds a feature to provide a structured, queryable record of all
logical replication conflicts. The current approach of logging conflicts as
plain text in the server logs makes it difficult to query, analyze, and
use for external monitoring and automation.
This patch addresses these limitations by introducing a configurable
conflict_log_table option in the CREATE SUBSCRIPTION command. Key design
decisions include:
User-Defined Table: The conflict log is stored in a user-managed table
rather than a system catalog.
Structured Data: Conflict details, including the original and remote tuples,
are stored in JSON columns, providing a flexible format to accommodate different
table schemas.
Comprehensive Information: The log table captures essential attributes such as
local and remote transaction IDs, LSNs, commit timestamps, and conflict type,
providing a complete record for post-mortem analysis.
This feature will make logical replication conflicts easier to monitor and manage,
significantly improving the overall resilience and operability of replication setups.
---
src/backend/commands/subscriptioncmds.c | 175 +++++++++++++++++++-
src/backend/replication/logical/conflict.c | 178 +++++++++++++++++++++
src/backend/replication/logical/worker.c | 10 +-
src/backend/utils/cache/lsyscache.c | 40 +++++
src/include/catalog/pg_subscription.h | 5 +
src/include/replication/conflict.h | 2 +
src/include/replication/worker_internal.h | 4 +
src/include/utils/lsyscache.h | 1 +
src/test/regress/expected/subscription.out | 79 +++++++++
src/test/regress/sql/subscription.sql | 55 +++++++
10 files changed, 545 insertions(+), 4 deletions(-)
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index 5930e8c5816..e48728dec07 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -34,6 +34,7 @@
#include "commands/event_trigger.h"
#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "executor/spi.h"
#include "miscadmin.h"
#include "nodes/makefuncs.h"
#include "pgstat.h"
@@ -47,10 +48,12 @@
#include "storage/lmgr.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/guc.h"
#include "utils/lsyscache.h"
#include "utils/memutils.h"
#include "utils/pg_lsn.h"
+#include "utils/regproc.h"
#include "utils/syscache.h"
/*
@@ -75,6 +78,7 @@
#define SUBOPT_MAX_RETENTION_DURATION 0x00008000
#define SUBOPT_LSN 0x00010000
#define SUBOPT_ORIGIN 0x00020000
+#define SUBOPT_CONFLICT_TABLE 0x00030000
/* check if the 'val' has 'bits' set */
#define IsSet(val, bits) (((val) & (bits)) == (bits))
@@ -103,6 +107,7 @@ typedef struct SubOpts
bool retaindeadtuples;
int32 maxretention;
char *origin;
+ char *conflicttable;
XLogRecPtr lsn;
} SubOpts;
@@ -135,7 +140,8 @@ static List *merge_publications(List *oldpublist, List *newpublist, bool addpub,
static void ReportSlotConnectionError(List *rstates, Oid subid, char *slotname, char *err);
static void CheckAlterSubOption(Subscription *sub, const char *option,
bool slot_needs_update, bool isTopLevel);
-
+static void CreateConflictLogTable(Oid namespaceId, char *conflictrel);
+static void DropConflictLogTable(Oid namespaceId, char *conflictrel);
/*
* Common option parsing function for CREATE and ALTER SUBSCRIPTION commands.
@@ -191,6 +197,8 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->maxretention = 0;
if (IsSet(supported_opts, SUBOPT_ORIGIN))
opts->origin = pstrdup(LOGICALREP_ORIGIN_ANY);
+ if (IsSet(supported_opts, SUBOPT_CONFLICT_TABLE))
+ opts->conflicttable = NULL;
/* Parse options */
foreach(lc, stmt_options)
@@ -402,6 +410,15 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->specified_opts |= SUBOPT_LSN;
opts->lsn = lsn;
}
+ else if (IsSet(supported_opts, SUBOPT_CONFLICT_TABLE) &&
+ strcmp(defel->defname, "conflict_log_table") == 0)
+ {
+ if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_TABLE))
+ errorConflictingDefElem(defel, pstate);
+
+ opts->specified_opts |= SUBOPT_CONFLICT_TABLE;
+ opts->conflicttable = defGetString(defel);
+ }
else
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
@@ -599,6 +616,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bits32 supported_opts;
SubOpts opts = {0};
AclResult aclresult;
+ Oid conflict_table_nspid;
+ char *conflict_table;
/*
* Parse and check options.
@@ -612,7 +631,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
SUBOPT_DISABLE_ON_ERR | SUBOPT_PASSWORD_REQUIRED |
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
- SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN);
+ SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_TABLE);
parse_subscription_options(pstate, stmt->options, supported_opts, &opts);
/*
@@ -747,6 +767,25 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
values[Anum_pg_subscription_suborigin - 1] =
CStringGetTextDatum(opts.origin);
+ /*
+ * If a conflict log table name is specified, parse the schema and table
+ * name from the string. Store the namespace OID and the table name in
+ * the pg_subscription catalog tuple.
+ */
+ if (opts.conflicttable)
+ {
+ List *names = stringToQualifiedNameList(opts.conflicttable, NULL);
+
+ conflict_table_nspid =
+ QualifiedNameGetCreationNamespace(names, &conflict_table);
+ values[Anum_pg_subscription_subconflictnspid - 1] =
+ ObjectIdGetDatum(conflict_table_nspid);
+ values[Anum_pg_subscription_subconflicttable - 1] =
+ CStringGetTextDatum(conflict_table);
+ }
+ else
+ nulls[Anum_pg_subscription_subconflicttable - 1] = true;
+
tup = heap_form_tuple(RelationGetDescr(rel), values, nulls);
/* Insert tuple into catalog. */
@@ -768,6 +807,10 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
ReplicationOriginNameForLogicalRep(subid, InvalidOid, originname, sizeof(originname));
replorigin_create(originname);
+ /* If conflict log table name is given than create the table. */
+ if (opts.conflicttable)
+ CreateConflictLogTable(conflict_table_nspid, conflict_table);
+
/*
* Connect to remote side to execute requested commands and fetch table
* and sequence info.
@@ -1410,7 +1453,8 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
SUBOPT_MAX_RETENTION_DURATION |
- SUBOPT_ORIGIN);
+ SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_TABLE);
parse_subscription_options(pstate, stmt->options,
supported_opts, &opts);
@@ -1665,6 +1709,25 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
origin = opts.origin;
}
+ if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_TABLE))
+ {
+ Oid nspid;
+ char *relname = NULL;
+ List *names =
+ stringToQualifiedNameList(opts.conflicttable, NULL);
+
+ nspid = QualifiedNameGetCreationNamespace(names, &relname);
+ values[Anum_pg_subscription_subconflictnspid - 1] =
+ ObjectIdGetDatum(nspid);
+ values[Anum_pg_subscription_subconflicttable - 1] =
+ CStringGetTextDatum(relname);
+
+ replaces[Anum_pg_subscription_subconflictnspid - 1] = true;
+ replaces[Anum_pg_subscription_subconflicttable - 1] = true;
+
+ CreateConflictLogTable(nspid, relname);
+ }
+
update_tuple = true;
break;
}
@@ -2027,6 +2090,8 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
Form_pg_subscription form;
List *rstates;
bool must_use_password;
+ Oid conflict_table_nsp = InvalidOid;
+ char *conflict_table = NULL;
/*
* The launcher may concurrently start a new worker for this subscription.
@@ -2110,6 +2175,19 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
ObjectAddressSet(myself, SubscriptionRelationId, subid);
EventTriggerSQLDropAddObject(&myself, true, true);
+ /* Fetch the conflict log table information. */
+ conflict_table = get_subscription_conflictrel(subid, &conflict_table_nsp);
+
+ /*
+ * If the subscription had a conflict log table, drop it now. This happens
+ * before deleting the subscription tuple.
+ */
+ if (conflict_table)
+ {
+ DropConflictLogTable(conflict_table_nsp, conflict_table);
+ pfree(conflict_table);
+ }
+
/* Remove the tuple from catalog. */
CatalogTupleDelete(rel, &tup->t_self);
@@ -3188,3 +3266,94 @@ defGetStreamingMode(DefElem *def)
def->defname)));
return LOGICALREP_STREAM_OFF; /* keep compiler quiet */
}
+
+/*
+ * Create conflict log table.
+ *
+ * The subscription owner becomes the owner of this table and has all
+ * privileges on it.
+ */
+static void
+CreateConflictLogTable(Oid namespaceId, char *conflictrel)
+{
+ StringInfoData querybuf;
+
+ /*
+ * Check if table with same name already present, if so report an error
+ * as currently we do not support user created table as conflict log
+ * table.
+ */
+ if (OidIsValid(get_relname_relid(conflictrel, namespaceId)))
+ ereport(ERROR,
+ (errcode(ERRCODE_DUPLICATE_TABLE),
+ errmsg("cannot create conflict log table \"%s.%s\" because a table with that name already exists",
+ get_namespace_name(namespaceId), conflictrel),
+ errhint("Use a different name for the conflict log table or drop the existing table.")));
+
+ initStringInfo(&querybuf);
+
+ /* build and execute the CREATE TABLE query. */
+ appendStringInfo(&querybuf,
+ "CREATE TABLE %s.%s ("
+ "relid Oid,"
+ "local_xid xid,"
+ "remote_xid xid,"
+ "remote_commit_lsn pg_lsn,"
+ "local_commit_ts TIMESTAMPTZ,"
+ "remote_commit_ts TIMESTAMPTZ,"
+ "table_schema TEXT,"
+ "table_name TEXT,"
+ "conflict_type TEXT,"
+ "local_origin TEXT,"
+ "remote_origin TEXT,"
+ "key_tuple JSON,"
+ "local_tuple JSON,"
+ "remote_tuple JSON)",
+ quote_identifier(get_namespace_name(namespaceId)),
+ quote_identifier(conflictrel));
+
+ if (SPI_connect() != SPI_OK_CONNECT)
+ elog(ERROR, "SPI_connect failed");
+
+ if (SPI_execute(querybuf.data, false, 0) != SPI_OK_UTILITY)
+ elog(ERROR, "SPI_exec failed: %s", querybuf.data);
+
+ if (SPI_finish() != SPI_OK_FINISH)
+ elog(ERROR, "SPI_finish failed");
+
+ pfree(querybuf.data);
+}
+
+/*
+ * Drop the conflict log table.
+ *
+ * This function uses SPI to execute DROP TABLE IF EXISTS.
+ * We use IF EXISTS to avoid errors if the user manually dropped it first.
+ */
+static void
+DropConflictLogTable(Oid namespaceId, char *conflictrel)
+{
+ StringInfoData querybuf;
+
+ initStringInfo(&querybuf);
+
+ /*
+ * Drop conflict log table if exist, use if exists ensures the command
+ * won't error if the table is already gone.
+ */
+ appendStringInfo(&querybuf,
+ "DROP TABLE IF EXISTS %s.%s",
+ quote_identifier(get_namespace_name(namespaceId)),
+ quote_identifier(conflictrel));
+
+ if (SPI_connect() != SPI_OK_CONNECT)
+ elog(ERROR, "SPI_connect failed");
+
+ if (SPI_execute(querybuf.data, false, 0) != SPI_OK_UTILITY)
+ elog(ERROR, "SPI_exec failed: %s", querybuf.data);
+
+ if (SPI_finish() != SPI_OK_FINISH)
+ elog(ERROR, "SPI_finish failed");
+
+ pfree(querybuf.data);
+}
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 16695592265..9cc607c92a2 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -15,13 +15,23 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/tableam.h"
+#include "access/table.h"
+#include "catalog/indexing.h"
+#include "catalog/namespace.h"
+#include "catalog/pg_namespace_d.h"
+#include "catalog/pg_type.h"
#include "executor/executor.h"
+#include "executor/spi.h"
#include "pgstat.h"
#include "replication/conflict.h"
#include "replication/worker_internal.h"
#include "storage/lmgr.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/lsyscache.h"
+#include "utils/pg_lsn.h"
static const char *const ConflictTypeNames[] = {
[CT_INSERT_EXISTS] = "insert_exists",
@@ -52,6 +62,16 @@ static char *build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
Oid indexoid);
static char *build_index_value_desc(EState *estate, Relation localrel,
TupleTableSlot *slot, Oid indexoid);
+static Datum TupleTableSlotToJsonDatum(TupleTableSlot *slot);
+
+static void InsertConflictLog(Relation rel,
+ TransactionId local_xid,
+ TimestampTz local_ts,
+ ConflictType conflict_type,
+ RepOriginId origin_id,
+ TupleTableSlot *searchslot,
+ TupleTableSlot *localslot,
+ TupleTableSlot *remoteslot);
/*
* Get the xmin and commit timestamp data (origin and timestamp) associated
@@ -112,6 +132,7 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
/* Form errdetail message by combining conflicting tuples information. */
foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
+ {
errdetail_apply_conflict(estate, relinfo, type, searchslot,
conflicttuple->slot, remoteslot,
conflicttuple->indexoid,
@@ -120,6 +141,15 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
conflicttuple->ts,
&err_detail);
+ /* Insert conflict details to log history table. */
+ InsertConflictLog(relinfo->ri_RelationDesc,
+ conflicttuple->xmin,
+ conflicttuple->ts, type,
+ conflicttuple->origin,
+ searchslot, conflicttuple->slot,
+ remoteslot);
+ }
+
pgstat_report_subscription_conflict(MySubscription->oid, type);
ereport(elevel,
@@ -525,3 +555,151 @@ build_index_value_desc(EState *estate, Relation localrel, TupleTableSlot *slot,
return index_value;
}
+
+/*
+ * Helper function to convert a TupleTableSlot to Jsonb
+ *
+ * This would be a new internal helper function for logical replication
+ * Needs to handle various data types and potentially TOASTed data
+ */
+static Datum
+TupleTableSlotToJsonDatum(TupleTableSlot *slot)
+{
+ HeapTuple tuple = ExecCopySlotHeapTuple(slot);
+ Datum datum = heap_copy_tuple_as_datum(tuple, slot->tts_tupleDescriptor);
+ Datum json;
+
+ if (TupIsNull(slot))
+ return 0;
+
+ json = DirectFunctionCall1(row_to_json, datum);
+ heap_freetuple(tuple);
+
+ return json;
+}
+
+/*
+ * InsertConflictLog
+ *
+ * Insert details about a logical replication conflict to a conflict history
+ * table.
+ */
+static void
+InsertConflictLog(Relation rel, TransactionId local_xid, TimestampTz local_ts,
+ ConflictType conflict_type, RepOriginId origin_id,
+ TupleTableSlot *searchslot, TupleTableSlot *localslot,
+ TupleTableSlot *remoteslot)
+{
+ Datum values[MAX_CONFLICT_ATTR_NUM];
+ bool nulls[MAX_CONFLICT_ATTR_NUM];
+ Oid nspid;
+ Oid relid;
+ Relation conflictrel;
+ int attno;
+ int options = HEAP_INSERT_NO_LOGICAL;
+ char *relname;
+ char *origin = NULL;
+ char *remote_origin = NULL;
+ HeapTuple tup;
+
+ /* If conflict history is not enabled for the subscription just return. */
+ relname = get_subscription_conflictrel(MyLogicalRepWorker->subid, &nspid);
+ if (relname == NULL)
+ return;
+
+ /* TODO: proper error code */
+ relid = get_relname_relid(relname, nspid);
+ if (!OidIsValid(relid))
+ elog(ERROR, "conflict log history table does not exists");
+ conflictrel = table_open(relid, RowExclusiveLock);
+ if (conflictrel == NULL)
+ elog(ERROR, "could not open conflict log history table");
+
+
+ /* Initialize values and nulls arrays */
+ memset(values, 0, sizeof(Datum) * MAX_CONFLICT_ATTR_NUM);
+ memset(nulls, 0, sizeof(bool) * MAX_CONFLICT_ATTR_NUM);
+
+ /* Populate the values and nulls arrays */
+ attno = 0;
+ values[attno] = ObjectIdGetDatum(RelationGetRelid(rel));
+ attno++;
+
+ if (TransactionIdIsValid(local_xid))
+ values[attno] = TransactionIdGetDatum(local_xid);
+ else
+ nulls[attno] = true;
+ attno++;
+
+ if (TransactionIdIsValid(remote_xid))
+ values[attno] = TransactionIdGetDatum(remote_xid);
+ else
+ nulls[attno] = true;
+ attno++;
+
+ values[attno] = LSNGetDatum(remote_final_lsn);
+ attno++;
+
+ if (local_ts > 0)
+ values[attno] = TimestampTzGetDatum(local_ts);
+ else
+ nulls[attno] = true;
+ attno++;
+
+ if (remote_commit_ts > 0)
+ values[attno] = TimestampTzGetDatum(remote_commit_ts);
+ else
+ nulls[attno] = true;
+ attno++;
+
+ values[attno] =
+ CStringGetTextDatum(get_namespace_name(RelationGetNamespace(rel)));
+ attno++;
+
+ values[attno] = CStringGetTextDatum(RelationGetRelationName(rel));
+ attno++;
+
+ values[attno] = CStringGetTextDatum(ConflictTypeNames[conflict_type]);
+ attno++;
+
+ if (origin_id != InvalidRepOriginId)
+ replorigin_by_oid(origin_id, true, &origin);
+
+ if (origin != NULL)
+ values[attno] = CStringGetTextDatum(origin);
+ else
+ nulls[attno] = true;
+ attno++;
+
+ if (replorigin_session_origin != InvalidRepOriginId)
+ replorigin_by_oid(replorigin_session_origin, true, &remote_origin);
+
+ if (remote_origin != NULL)
+ values[attno] = CStringGetTextDatum(remote_origin);
+ else
+ nulls[attno] = true;
+ attno++;
+
+ if (searchslot != NULL)
+ values[attno] = TupleTableSlotToJsonDatum(searchslot);
+ else
+ nulls[attno] = true;
+ attno++;
+
+ if (localslot != NULL)
+ values[attno] = TupleTableSlotToJsonDatum(localslot);
+ else
+ nulls[attno] = true;
+ attno++;
+
+ if (remoteslot != NULL)
+ values[attno] = TupleTableSlotToJsonDatum(remoteslot);
+ else
+ nulls[attno] = true;
+
+ tup = heap_form_tuple(RelationGetDescr(conflictrel), values, nulls);
+ heap_insert(conflictrel, tup, GetCurrentCommandId(true), options, NULL);
+ table_close(conflictrel, RowExclusiveLock);
+
+ pfree(relname);
+}
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 93970c6af29..e6c02685ec9 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -482,7 +482,9 @@ static bool MySubscriptionValid = false;
static List *on_commit_wakeup_workers_subids = NIL;
bool in_remote_transaction = false;
-static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+TransactionId remote_xid = InvalidTransactionId;
+TimestampTz remote_commit_ts = 0;
/* fields valid only when processing streamed transaction */
static bool in_streamed_transaction = false;
@@ -1219,6 +1221,8 @@ apply_handle_begin(StringInfo s)
set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
remote_final_lsn = begin_data.final_lsn;
+ remote_commit_ts = begin_data.committime;
+ remote_xid = begin_data.xid;
maybe_start_skipping_changes(begin_data.final_lsn);
@@ -1745,6 +1749,10 @@ apply_handle_stream_start(StringInfo s)
/* extract XID of the top-level transaction */
stream_xid = logicalrep_read_stream_start(s, &first_segment);
+ remote_xid = stream_xid;
+ remote_final_lsn = InvalidXLogRecPtr;
+ remote_commit_ts = 0;
+
if (!TransactionIdIsValid(stream_xid))
ereport(ERROR,
(errcode(ERRCODE_PROTOCOL_VIOLATION),
diff --git a/src/backend/utils/cache/lsyscache.c b/src/backend/utils/cache/lsyscache.c
index fa7cd7e06a7..34fe347ebb7 100644
--- a/src/backend/utils/cache/lsyscache.c
+++ b/src/backend/utils/cache/lsyscache.c
@@ -3881,3 +3881,43 @@ get_subscription_name(Oid subid, bool missing_ok)
return subname;
}
+
+/*
+ * get_subscription_conflictrel
+ *
+ * Get conflict relation name and namespace id from subscription.
+ */
+char *
+get_subscription_conflictrel(Oid subid, Oid *nspid)
+{
+ HeapTuple tup;
+ Datum datum;
+ bool isnull;
+ char *relname;
+ Form_pg_subscription subform;
+
+ tup = SearchSysCache1(SUBSCRIPTIONOID, ObjectIdGetDatum(subid));
+
+ if (!HeapTupleIsValid(tup))
+ return NULL;
+
+ subform = (Form_pg_subscription) GETSTRUCT(tup);
+
+ /* Get conflict table name */
+ datum = SysCacheGetAttr(SUBSCRIPTIONOID,
+ tup,
+ Anum_pg_subscription_subconflicttable,
+ &isnull);
+ if (isnull)
+ {
+ ReleaseSysCache(tup);
+ return NULL;
+ }
+
+ *nspid = subform->subconflictnspid;
+ relname = pstrdup(TextDatumGetCString(datum));
+
+ ReleaseSysCache(tup);
+
+ return relname;
+}
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index 55cb9b1eefa..ec31e2b1d56 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -80,6 +80,8 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
bool subretaindeadtuples; /* True if dead tuples useful for
* conflict detection are retained */
+ Oid subconflictnspid; /* Namespace Oid in which the conflict history
+ * table is created. */
int32 submaxretention; /* The maximum duration (in milliseconds)
* for which information useful for
@@ -105,6 +107,9 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
/* Only publish data originating from the specified origin */
text suborigin BKI_DEFAULT(LOGICALREP_ORIGIN_ANY);
+
+ /* conflict log history table name if valid */
+ text subconflicttable;
#endif
} FormData_pg_subscription;
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index c8fbf9e51b8..adc46e79286 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -62,6 +62,7 @@ typedef enum
} ConflictType;
#define CONFLICT_NUM_TYPES (CT_MULTIPLE_UNIQUE_CONFLICTS + 1)
+#define MAX_CONFLICT_ATTR_NUM 15
/*
* Information for the existing local row that caused the conflict.
@@ -89,4 +90,5 @@ extern void ReportApplyConflict(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *remoteslot,
List *conflicttuples);
extern void InitConflictIndexes(ResultRelInfo *relInfo);
+
#endif
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index f081619f151..314ac5dc746 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -256,6 +256,10 @@ extern PGDLLIMPORT bool InitializingApplyWorker;
extern PGDLLIMPORT List *table_states_not_ready;
+extern XLogRecPtr remote_final_lsn;
+extern TimestampTz remote_commit_ts;
+extern TransactionId remote_xid;
+
extern void logicalrep_worker_attach(int slot);
extern LogicalRepWorker *logicalrep_worker_find(LogicalRepWorkerType wtype,
Oid subid, Oid relid,
diff --git a/src/include/utils/lsyscache.h b/src/include/utils/lsyscache.h
index 50fb149e9ac..dc6df5843a4 100644
--- a/src/include/utils/lsyscache.h
+++ b/src/include/utils/lsyscache.h
@@ -210,6 +210,7 @@ extern Oid get_publication_oid(const char *pubname, bool missing_ok);
extern char *get_publication_name(Oid pubid, bool missing_ok);
extern Oid get_subscription_oid(const char *subname, bool missing_ok);
extern char *get_subscription_name(Oid subid, bool missing_ok);
+extern char *get_subscription_conflictrel(Oid subid, Oid *nspid);
#define type_is_array(typid) (get_element_type(typid) != InvalidOid)
/* type_is_array_domain accepts both plain arrays and domains over arrays */
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index 327d1e7731f..72eea5e1601 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -517,6 +517,85 @@ COMMIT;
-- ok, owning it is enough for this stuff
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG HISTORY TABLE TESTS
+--
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+-- fail - conflict_log_table specified when table already exists
+CREATE TABLE public.regress_conflict_log_temp (id int);
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log_temp');
+ERROR: cannot create conflict history table "public.regress_conflict_log_temp" because a table with that name already exists
+HINT: Use a different name for the conflict history table or drop the existing table.
+DROP TABLE public.regress_conflict_log_temp;
+-- ok - conflict_log_table creation with CREATE SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- check metadata in pg_subscription
+SELECT subname, subconflicttable, subconflictnspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname | subconflicttable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test1 | regress_conflict_log1 | t
+(1 row)
+
+-- check if the table exists and has the correct schema (15 columns)
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attnum > 0;
+ count
+-------
+ 14
+(1 row)
+
+-- check a specific column type (e.g., key_tuple should be JSON)
+SELECT format_type(atttypid, atttypmod) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attname = 'key_tuple';
+ format_type
+-------------
+ json
+(1 row)
+
+-- ok - adding conflict_log_table with ALTER SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log2');
+-- check metadata after ALTER
+SELECT subname, subconflicttable, subconflictnspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflicttable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test2 | regress_conflict_log2 | t
+(1 row)
+
+-- ok - dropping subscription also drops the log table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the table was dropped
+SELECT to_regclass('public.regress_conflict_log1');
+ to_regclass
+-------------
+
+(1 row)
+
+-- ok - dropping subscription when the log table was manually dropped first
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+DROP TABLE public.regress_conflict_log1;
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+NOTICE: table "regress_conflict_log1" does not exist, skipping
+-- should return NULL, meaning the subscription was dropped successfully
+SELECT subname FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname
+---------
+(0 rows)
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
RESET SESSION AUTHORIZATION;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index ef0c298d2df..02afbf5c213 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -365,6 +365,61 @@ COMMIT;
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG HISTORY TABLE TESTS
+--
+
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+
+-- fail - conflict_log_table specified when table already exists
+CREATE TABLE public.regress_conflict_log_temp (id int);
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log_temp');
+DROP TABLE public.regress_conflict_log_temp;
+
+-- ok - conflict_log_table creation with CREATE SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+
+-- check metadata in pg_subscription
+SELECT subname, subconflicttable, subconflictnspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- check if the table exists and has the correct schema (15 columns)
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attnum > 0;
+
+-- check a specific column type (e.g., key_tuple should be JSON)
+SELECT format_type(atttypid, atttypmod) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attname = 'key_tuple';
+
+-- ok - adding conflict_log_table with ALTER SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log2');
+
+-- check metadata after ALTER
+SELECT subname, subconflicttable, subconflictnspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- ok - dropping subscription also drops the log table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the table was dropped
+SELECT to_regclass('public.regress_conflict_log1');
+
+-- ok - dropping subscription when the log table was manually dropped first
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+DROP TABLE public.regress_conflict_log1;
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the subscription was dropped successfully
+SELECT subname FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+
RESET SESSION AUTHORIZATION;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
--
2.49.0
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-11-18 10:09 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
2 siblings, 1 reply; 912+ messages in thread
From: shveta malik @ 2025-11-18 10:09 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Thu, Nov 13, 2025 at 9:17 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Thu, Nov 13, 2025 at 2:39 PM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > > Few observations related to publication.
> > > ------------------------------
>
> Thanks Shveta, for testing and sharing your thoughts. IMHO for
> conflict log tables it should be good enough if we restrict it when
> ALL TABLE options are used, I don't think we need to put extra effort
> to completely restrict it even if users want to explicitly list it
> into the publication.
>
> > >
> > > (In the below comments, clt/CLT implies Conflict Log Table)
> > >
> > > 1)
> > > 'select pg_relation_is_publishable(clt)' returns true for conflict-log table.
>
> This function is used while publishing every single change and I don't
> think we want to add a cost to check each subscription to identify
> whether the table is listed as CLT.
>
> > > 2)
> > > '\d+ clt' shows all-tables publication name. I feel we should not
> > > show that for clt.
>
> I think we should fix this.
>
> > > 3)
> > > I am able to create a publication for clt table, should it be allowed?
>
> I believe we should not do any specific handling to restrict this but
> I am open for the opinions.
>
> > > create subscription sub1 connection '...' publication pub1
> > > WITH(conflict_log_table='clt');
> > > create publication pub3 for table clt;
> > >
> > > 4)
> > > Is there a reason we have not made '!IsConflictHistoryRelid' check as
> > > part of is_publishable_class() itself? If we do so, other code-logics
> > > will also get clt as non-publishable always (and will solve a few of
> > > the above issues I think). IIUC, there is no place where we want to
> > > mark CLT as publishable or is there any?
>
> IMHO the main reason is performance.
>
> > > 5) Also, I feel we can add some documentation now to help others to
> > > understand/review the patch better without going through the long
> > > thread.
>
> Make sense, I will do that in the next version.
>
> > >
> > > Few observations related to conflict-logging:
> > > ------------------------------
> > > 1)
> > > I found that for the conflicts which ultimately result in Error, we do
> > > not insert any conflict-record in clt.
> > >
> > > a)
> > > Example: insert_exists, update_Exists
> > > create table tab1 (i int primary key, j int);
> > > sub: insert into tab1 values(30,10);
> > > pub: insert into tab1 values(30,10);
> > > ERROR: conflict detected on relation "public.tab1": conflict=insert_exists
> > > No record in clt.
> > >
> > > sub:
> > > <some pre-data needed>
> > > update tab1 set i=40 where i = 30;
> > > pub: update tab1 set i=40 where i = 20;
> > > ERROR: conflict detected on relation "public.tab1": conflict=update_exists
> > > No record in clt.
>
> Yeah that interesting need to put thought on how to commit this record
> when an outer transaction is aborted as we do not have autonomous
> transactions which are generally used for this kind of logging.
Right
> But
> we can explore more options like inserting into conflict log tables
> outside the outer transaction.
Yes, that seems the way to me. I could not find any such existing
reference/usage in code though.
>
> > > b)
> > > Another question related to this is, since these conflicts (which
> > > results in error) keep on happening until user resolves these or skips
> > > these or 'disable_on_error' is set. Then are we going to insert these
> > > multiple times? We do count these in 'confl_insert_exists' and
> > > 'confl_update_exists' everytime, so it makes sense to log those each
> > > time in clt as well. Thoughts?
>
> I think it make sense to insert every time we see the conflict, but it
> would be good to have opinion from others as well.
>
> > > 2)
> > > Conflicts where row on sub is missing, local_ts incorrectly inserted.
> > > It is '2000-01-01 05:30:00+05:30'. Should it be Null or something
> > > indicating that it is not applicable for this conflict-type?
> > >
> > > Example: delete_missing, update_missing
> > > pub:
> > > insert into tab1 values(10,10);
> > > insert into tab1 values(20,10);
> > > sub: delete from tab1 where i=10;
> > > pub: delete from tab1 where i=10;
>
> Sure I will test this.
>
> >
> > 3)
> > We also need to think how we are going to display the info in case of
> > multiple_unique_conflicts as there could be multiple local and remote
> > tuples conflicting for one single operation. Example:
> >
> > create table conf_tab (a int primary key, b int unique, c int unique);
> >
> > sub: insert into conf_tab values (2,2,2), (3,3,3), (4,4,4);
> >
> > pub: insert into conf_tab values (2,3,4);
> >
> > ERROR: conflict detected on relation "public.conf_tab":
> > conflict=multiple_unique_conflicts
> > DETAIL: Key already exists in unique index "conf_tab_pkey", modified
> > locally in transaction 874 at 2025-11-12 14:35:13.452143+05:30.
> > Key (a)=(2); existing local row (2, 2, 2); remote row (2, 3, 4).
> > Key already exists in unique index "conf_tab_b_key", modified locally
> > in transaction 874 at 2025-11-12 14:35:13.452143+05:30.
> > Key (b)=(3); existing local row (3, 3, 3); remote row (2, 3, 4).
> > Key already exists in unique index "conf_tab_c_key", modified locally
> > in transaction 874 at 2025-11-12 14:35:13.452143+05:30.
> > Key (c)=(4); existing local row (4, 4, 4); remote row (2, 3, 4).
> > CONTEXT: processing remote data for replication origin "pg_16392"
> > during message type "INSERT" for replication target relation
> > "public.conf_tab" in transaction 781, finished at 0/017FDDA0
> >
> > Currently in clt, we have singular terms such as 'key_tuple',
> > 'local_tuple', 'remote_tuple'. Shall we have multiple rows inserted?
> > But it does not look reasonable to have multiple rows inserted for a
> > single conflict raised. I will think more about this.
>
> Currently I am inserting multiple records in the conflict history
> table, the same as each tuple is logged, but couldn't find any better
> way for this. Another option is to use an array of tuples instead of a
> single tuple but not sure this might make things more complicated to
> process by any external tool.
It’s arguable and hard to say what the correct behaviour should be.
I’m slightly leaning toward having a single row per conflict. IMO,
overall the confl_* counters in pg_stat_subscription_stats should
align with the number of entries in the conflict history table, which
implies one row even for multiple_unique_conflicts. But I also
understand that this approach could make things complicated for
external tools. For now, we can proceed with logging multiple rows for
a single multiple_unique_conflicts occurrence and wait to hear others’
opinions.
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-11-18 11:17 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
3 siblings, 1 reply; 912+ messages in thread
From: shveta malik @ 2025-11-18 11:17 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Mon, Nov 17, 2025 at 11:54 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Thu, Nov 13, 2025 at 9:17 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Thu, Nov 13, 2025 at 2:39 PM shveta malik <shveta.malik@gmail.com> wrote:
> > >
> > > > Few observations related to publication.
> > > > ------------------------------
> >
> > Thanks Shveta, for testing and sharing your thoughts. IMHO for
> > conflict log tables it should be good enough if we restrict it when
> > ALL TABLE options are used, I don't think we need to put extra effort
> > to completely restrict it even if users want to explicitly list it
> > into the publication.
> >
> > > >
> > > > (In the below comments, clt/CLT implies Conflict Log Table)
> > > >
> > > > 1)
> > > > 'select pg_relation_is_publishable(clt)' returns true for conflict-log table.
>
> After putting more thought I have changed this to return false for
> clt, as this is just an exposed function not called by pgoutput layer.
>
> > > > 2)
> > > > '\d+ clt' shows all-tables publication name. I feel we should not
> > > > show that for clt.
> >
> Fixed
>
> >
> > > > 3)
> > > > I am able to create a publication for clt table, should it be allowed?
> >
> > I believe we should not do any specific handling to restrict this but
> > I am open for the opinions.
>
> Restricting this as well, lets see what others think.
>
>
> >
> > > > 5) Also, I feel we can add some documentation now to help others to
> > > > understand/review the patch better without going through the long
> > > > thread.
> >
> > Make sense, I will do that in the next version.
> Done that but not compiled the docs as I don't currently have the
> setup so added as WIP patch.
>
>
> > > > 2)
> > > > Conflicts where row on sub is missing, local_ts incorrectly inserted.
> > > > It is '2000-01-01 05:30:00+05:30'. Should it be Null or something
> > > > indicating that it is not applicable for this conflict-type?
> > > >
> > > > Example: delete_missing, update_missing
> > > > pub:
> > > > insert into tab1 values(10,10);
> > > > insert into tab1 values(20,10);
> > > > sub: delete from tab1 where i=10;
> > > > pub: delete from tab1 where i=10;
> >
> > Sure I will test this.
>
> I have fixed this.
Thanks for the patch. Some feedback about the clt:
1)
local_origin is always NULL in my tests for all conflict types I tried.
2)
Do we need 'key_tuple' as such or replica_identity is enough/better?
I see 'key_tuple' inserted as {"i":10,"j":null} for delete_missing
case where query was 'delete from tab1 where i=10'; here 'i' is PK;
which seems okay.
But it is '{"i":20,"j":200}' for update_origin_differ case where query
was 'update tab1 set j=200 where i =20'. Here too RI is 'i' alone. I
feel 'j' should not be part of the key but let me know if I have
misunderstood. IMO, 'j' being part of remote_tuple should be good
enough.
3)
Do we need to have a timestamp column as well to say when conflict was
recorded? Or local_commit_ts, remote_commit_ts are sufficient?
Thoughts
4)
Also, it makes sense if we have 'conflict_type' next to 'relid'. I
feel relid and conflict_type are primary columns and rest are related
details.
5)
Do we need table_schema, table_name when we have relid already? If we
want to retain these, we can name them as schemaname and relname to be
consistent with all other stats tables. IMO, then the order can be:
relid, schemaname, relname, conflcit_type and then the rest of the
details.
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-11-19 01:30 Peter Smith <smithpb2250@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
3 siblings, 1 reply; 912+ messages in thread
From: Peter Smith @ 2025-11-19 01:30 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Hi Dilip.
I started to look at this thread. Here are some comments for patch v4-0001.
=====
GENERAL
1.
There's some inconsistency in how this new table is called at different times :
a) "conflict table"
b) "conflict log table"
c) "conflict log history table"
d) "conflict history"
My preference was (b). Making this consistent will have impacts on
many macros, variables, comments, function names, etc.
~~~
2.
What about enhancements to description \dRs+ so the subscription
conflict log table is displayed?
~~~
3.
What about enhancements to the tab-complete code?
======
src/backend/commands/subscriptioncmds.c
4.
#define SUBOPT_MAX_RETENTION_DURATION 0x00008000
#define SUBOPT_LSN 0x00010000
#define SUBOPT_ORIGIN 0x00020000
+#define SUBOPT_CONFLICT_TABLE 0x00030000
Bug? Shouldn't that be 0x00040000.
~~~
5.
+ char *conflicttable;
XLogRecPtr lsn;
} SubOpts;
IMO 'conflicttable' looks too much like 'conflictable', which may
cause some confusion on first reading.
~~~
6.
+static void CreateConflictLogTable(Oid namespaceId, char *conflictrel);
+static void DropConflictLogTable(Oid namespaceId, char *conflictrel);
AFAIK it is more conventional for the static functions to be
snake_case and the extern functions to use CamelCase. So these would
be:
- create_conflict_log_table
- drop_conflict_log_table
~~~
CreateSubscription:
7.
+ /* If conflict log table name is given than create the table. */
+ if (opts.conflicttable)
+ CreateConflictLogTable(conflict_table_nspid, conflict_table);
+
typo: /If conflict/If a conflict/
typo: "than"
~~~
AlterSubscription:
8.
- SUBOPT_ORIGIN);
+ SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_TABLE);
The line wrapping doesn't seem necessary.
~~~
9.
+ replaces[Anum_pg_subscription_subconflictnspid - 1] = true;
+ replaces[Anum_pg_subscription_subconflicttable - 1] = true;
+
+ CreateConflictLogTable(nspid, relname);
+ }
+
What are the rules regarding replacing one log table with a different
log table for the same subscription? I didn't see anything about this
scenario, nor any test cases.
~~~
CreateConflictLogTable:
10.
+ /*
+ * Check if table with same name already present, if so report an error
+ * as currently we do not support user created table as conflict log
+ * table.
+ */
Is the comment about "user-created table" strictly correct? e.g. Won't
you encounter the same problem if there are 2 subscriptions trying to
set the same-named conflict log table?
SUGGESTION
Report an error if the specified conflict log table already exists.
~~~
DropConflictLogTable:
11.
+ /*
+ * Drop conflict log table if exist, use if exists ensures the command
+ * won't error if the table is already gone.
+ */
The reason for EXISTS was already mentioned in the function comment.
SUGGESTION
Drop the conflict log table if it exists.
======
src/backend/replication/logical/conflict.c
12.
+static Datum TupleTableSlotToJsonDatum(TupleTableSlot *slot);
+
+static void InsertConflictLog(Relation rel,
+ TransactionId local_xid,
+ TimestampTz local_ts,
+ ConflictType conflict_type,
+ RepOriginId origin_id,
+ TupleTableSlot *searchslot,
+ TupleTableSlot *localslot,
+ TupleTableSlot *remoteslot);
Same as earlier comment #6 -- isn't it conventional to use snake_case
for the static function names?
~~~
TupleTableSlotToJsonDatum:
13.
+ * This would be a new internal helper function for logical replication
+ * Needs to handle various data types and potentially TOASTed data
What's this comment about? Something doesn't look quite right.
~~~
InsertConflictLog:
14.
+ /* TODO: proper error code */
+ relid = get_relname_relid(relname, nspid);
+ if (!OidIsValid(relid))
+ elog(ERROR, "conflict log history table does not exists");
+ conflictrel = table_open(relid, RowExclusiveLock);
+ if (conflictrel == NULL)
+ elog(ERROR, "could not open conflict log history table");
14a.
What's the TODO comment for? Are you going to replace these elogs?
~
14b.
Typo: "does not exists"
~
14c.
An unnecessary double-blank line follows this code fragment.
~~~
15.
+ /* Populate the values and nulls arrays */
+ attno = 0;
+ values[attno] = ObjectIdGetDatum(RelationGetRelid(rel));
+ attno++;
+
+ if (TransactionIdIsValid(local_xid))
+ values[attno] = TransactionIdGetDatum(local_xid);
+ else
+ nulls[attno] = true;
+ attno++;
+
+ if (TransactionIdIsValid(remote_xid))
+ values[attno] = TransactionIdGetDatum(remote_xid);
+ else
+ nulls[attno] = true;
+ attno++;
+
+ values[attno] = LSNGetDatum(remote_final_lsn);
+ attno++;
+
+ if (local_ts > 0)
+ values[attno] = TimestampTzGetDatum(local_ts);
+ else
+ nulls[attno] = true;
+ attno++;
+
+ if (remote_commit_ts > 0)
+ values[attno] = TimestampTzGetDatum(remote_commit_ts);
+ else
+ nulls[attno] = true;
+ attno++;
+
+ values[attno] =
+ CStringGetTextDatum(get_namespace_name(RelationGetNamespace(rel)));
+ attno++;
+
+ values[attno] = CStringGetTextDatum(RelationGetRelationName(rel));
+ attno++;
+
+ values[attno] = CStringGetTextDatum(ConflictTypeNames[conflict_type]);
+ attno++;
+
+ if (origin_id != InvalidRepOriginId)
+ replorigin_by_oid(origin_id, true, &origin);
+
+ if (origin != NULL)
+ values[attno] = CStringGetTextDatum(origin);
+ else
+ nulls[attno] = true;
+ attno++;
+
+ if (replorigin_session_origin != InvalidRepOriginId)
+ replorigin_by_oid(replorigin_session_origin, true, &remote_origin);
+
+ if (remote_origin != NULL)
+ values[attno] = CStringGetTextDatum(remote_origin);
+ else
+ nulls[attno] = true;
+ attno++;
+
+ if (searchslot != NULL)
+ values[attno] = TupleTableSlotToJsonDatum(searchslot);
+ else
+ nulls[attno] = true;
+ attno++;
+
+ if (localslot != NULL)
+ values[attno] = TupleTableSlotToJsonDatum(localslot);
+ else
+ nulls[attno] = true;
+ attno++;
+
+ if (remoteslot != NULL)
+ values[attno] = TupleTableSlotToJsonDatum(remoteslot);
+ else
+ nulls[attno] = true;
+
15a.
It might be simpler to just post-increment that 'attno' in all the
assignments and save a dozen lines of code:
e.g. values[attno++] = ...
~
15b.
Also, put a sanity Assert check at the end, like:
Assert(attno + 1 == MAX_CONFLICT_ATTR_NUM);
======
src/backend/utils/cache/lsyscache.c
16.
+ if (isnull)
+ {
+ ReleaseSysCache(tup);
+ return NULL;
+ }
+
+ *nspid = subform->subconflictnspid;
+ relname = pstrdup(TextDatumGetCString(datum));
+
+ ReleaseSysCache(tup);
+
+ return relname;
It would be tidier to have a single release/return by coding this
slightly differently.
SUGGESTION:
char *relname = NULL;
...
if (!isnull)
{
*nspid = subform->subconflictnspid;
relname = pstrdup(TextDatumGetCString(datum));
}
ReleaseSysCache(tup);
return relname;
======
src/include/catalog/pg_subscription.h
17.
+ Oid subconflictnspid; /* Namespace Oid in which the conflict history
+ * table is created. */
Would it be better to make these 2 new member names more alike, since
they go together. e.g.
confl_table_nspid
confl_table_name
======
src/include/replication/conflict.h
18.
+#define MAX_CONFLICT_ATTR_NUM 15
I felt this doesn't really belong here. Just define it atop/within the
function InsertConflictLog()
~~~
19.
extern void InitConflictIndexes(ResultRelInfo *relInfo);
+
#endif
Spurious whitespace change not needed for this patch.
======
src/test/regress/sql/subscription.sql
20.
How about adding some more test scenarios:
e.g.1. ALTER the conflict log table of some subscription that already has one
e.g.2. Have multiple subscriptions that specify the same conflict log table
======
Kind Regards,
Peter Smith.
Fujitsu Australia
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-11-19 06:29 Peter Smith <smithpb2250@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
3 siblings, 0 replies; 912+ messages in thread
From: Peter Smith @ 2025-11-19 06:29 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Here are some comments for the patch v4-0002.
======
GENERAL
1.
The patch should include test cases:
- to confirm an error happens when attempting to publish clt
- to confirm \dt+ clt is not showing the ALL TABLES publication
- to confirm that SQL function pg_relation_is_publishable givesthe
expected result
- etc.
======
Commit Message
1.
When all table option is used with publication don't publish the
conflict history tables.
~
Maybe reword that using uppercase for keywords, like:
SUGGESTION
A conflict log table will not be published by a FOR ALL TABLES publication.
======
src/backend/catalog/pg_publication.c
check_publication_add_relation:
3.
+ /* Can't be created as conflict log table */
+ if (IsConflictLogRelid(RelationGetRelid(targetrel)))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot add relation \"%s\" to publication",
+ RelationGetRelationName(targetrel)),
+ errdetail("This operation is not supported for conflict log tables.")));
3a.
Typo in comment.
SUGGESTION
Can't be a conflict log table
~
3b.
I was wondering if this check should be moved to the bottom of the function.
I think IsConflictLogRelid() is the most inefficient of all these
conditions, so it is better to give the other ones a chance to fail
quickly before needing to check for clt.
~~~
pg_relation_is_publishable:
4.
/*
- * SQL-callable variant of the above
+ * SQL-callable variant of the above and this should not be a conflict log rel
*
* This returns null when the relation does not exist. This is intended to be
* used for example in psql to avoid gratuitous errors when there are
I felt this new comment should be in the code, instead of in the
function comment.
SUGGESTION
/* subscription conflict log tables are not published */
result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple)) &&
!IsConflictLogRelid(relid);
~~~
5.
It seemed strange that function
pg_relation_is_publishable(PG_FUNCTION_ARGS) is checking
IsConflictLogRelid, but function is_publishable_relation(Relation rel)
is not.
~~~
GetAllPublicationRelations:
6.
+ /* conflict history tables are not published. */
if (is_publishable_class(relid, relForm) &&
+ !IsConflictLogRelid(relid) &&
!(relForm->relispartition && pubviaroot))
result = lappend_oid(result, relid);
Inconsistent "history table" terminology.
Maybe this comment should be identical to the other one above. e.g.
/* subscription conflict log tables are not published */
======
src/backend/commands/subscriptioncmds.c
IsConflictLogRelid:
8.
+/*
+ * Is relation used as a conflict log table
+ *
+ * Scan all the subscription and check whether the relation is used as
+ * conflict log table.
+ */
typo: "all the subscription"
Also, the 2nd sentence repeats the purpose of the function; I don't
think you need to say it twice.
SUGGESTION
Check if the specified relation is used as a conflict log table by any
subscription.
~~~
9.
+ if (relname == NULL)
+ continue;
+ if (relid == get_relname_relid(relname, nspid))
+ {
+ found = true;
+ break;
+ }
It seemed unnecessary to separate out the 'continue' like that.
In passing, consider renaming that generic 'found' to be the proper
meaning of the boolean.
SUGGESTION
if (relname && relid == get_relname_relid(relname, nspid))
{
is_clt = true;
break;
}
======
Kind Regards,
Peter Smith.
Fujitsu Australia
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-11-19 07:10 Peter Smith <smithpb2250@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
3 siblings, 0 replies; 912+ messages in thread
From: Peter Smith @ 2025-11-19 07:10 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Hi Dilip,
FYI, patch v4-0003 (docs) needs rebasing due to ada78cd.
======
Kind Regards,
Peter Smith.
Fujitsu Australia
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-11-19 10:16 Dilip Kumar <dilipbalaut@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 2 replies; 912+ messages in thread
From: Dilip Kumar @ 2025-11-19 10:16 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, Nov 18, 2025 at 4:47 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> Thanks for the patch. Some feedback about the clt:
>
> 1)
> local_origin is always NULL in my tests for all conflict types I tried.
You need to set the replication origin as shown below
On subscriber side:
---------------------------
SELECT pg_replication_origin_create('my_remote_source_2');
SELECT pg_replication_origin_session_setup('my_remote_source_2');
UPDATE test SET b=200 where a=1;
On remote:
---------------
UPDATE test SET b=300 where a=1; -- conflicting operation with local node
On subscriber
------------------
postgres[1514377]=# select local_origin, remote_origin from
myschema.conflict_log_history2 ;
local_origin | remote_origin
--------------------+---------------------
my_remote_source_2 | pg_16396
> 2)
> Do we need 'key_tuple' as such or replica_identity is enough/better?
> I see 'key_tuple' inserted as {"i":10,"j":null} for delete_missing
> case where query was 'delete from tab1 where i=10'; here 'i' is PK;
> which seems okay.
> But it is '{"i":20,"j":200}' for update_origin_differ case where query
> was 'update tab1 set j=200 where i =20'. Here too RI is 'i' alone. I
> feel 'j' should not be part of the key but let me know if I have
> misunderstood. IMO, 'j' being part of remote_tuple should be good
> enough.
Yeah we should display the replica identity only, I assumed in
ReportApplyConflict() the searchslot should only have RI tuple but it
is sending a remote tuple in the searchslot, so might need to extract
the RI from this slot, I will work on this.
> 3)
> Do we need to have a timestamp column as well to say when conflict was
> recorded? Or local_commit_ts, remote_commit_ts are sufficient?
> Thoughts
You mean we can record the timestamp now while inserting, not sure if
it will add some more meaningful information than remote_commit_ts,
but let's see what others think.
> 4)
> Also, it makes sense if we have 'conflict_type' next to 'relid'. I
> feel relid and conflict_type are primary columns and rest are related
> details.
Sure
> 5)
> Do we need table_schema, table_name when we have relid already? If we
> want to retain these, we can name them as schemaname and relname to be
> consistent with all other stats tables. IMO, then the order can be:
> relid, schemaname, relname, conflcit_type and then the rest of the
> details.
Yeah this makes the table denormalized as we can fetch this
information by joining with pg_class, but I think it might be better
for readability, lets see what others think, for now I will reorder as
suggested.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-11-19 11:19 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
1 sibling, 0 replies; 912+ messages in thread
From: shveta malik @ 2025-11-19 11:19 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Wed, Nov 19, 2025 at 3:46 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Tue, Nov 18, 2025 at 4:47 PM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > Thanks for the patch. Some feedback about the clt:
> >
> > 1)
> > local_origin is always NULL in my tests for all conflict types I tried.
>
> You need to set the replication origin as shown below
> On subscriber side:
> ---------------------------
> SELECT pg_replication_origin_create('my_remote_source_2');
> SELECT pg_replication_origin_session_setup('my_remote_source_2');
> UPDATE test SET b=200 where a=1;
>
> On remote:
> ---------------
> UPDATE test SET b=300 where a=1; -- conflicting operation with local node
>
> On subscriber
> ------------------
> postgres[1514377]=# select local_origin, remote_origin from
> myschema.conflict_log_history2 ;
> local_origin | remote_origin
> --------------------+---------------------
> my_remote_source_2 | pg_16396
Okay, I see, thanks!
>
> > 2)
> > Do we need 'key_tuple' as such or replica_identity is enough/better?
> > I see 'key_tuple' inserted as {"i":10,"j":null} for delete_missing
> > case where query was 'delete from tab1 where i=10'; here 'i' is PK;
> > which seems okay.
> > But it is '{"i":20,"j":200}' for update_origin_differ case where query
> > was 'update tab1 set j=200 where i =20'. Here too RI is 'i' alone. I
> > feel 'j' should not be part of the key but let me know if I have
> > misunderstood. IMO, 'j' being part of remote_tuple should be good
> > enough.
>
> Yeah we should display the replica identity only, I assumed in
> ReportApplyConflict() the searchslot should only have RI tuple but it
> is sending a remote tuple in the searchslot, so might need to extract
> the RI from this slot, I will work on this.
yeah, we have extracted it already in
errdetail_apply_conflict()->build_tuple_value_details(). See it dumps
it in log:
LOG: conflict detected on relation "public.tab1":
conflict=update_origin_differs
DETAIL: Updating the row that was modified locally in transaction 768
at 2025-11-18 12:09:19.658502+05:30.
Existing local row (20, 100); remote row (20, 200); replica
identity (i)=(20).
We somehow need to reuse it.
>
> > 3)
> > Do we need to have a timestamp column as well to say when conflict was
> > recorded? Or local_commit_ts, remote_commit_ts are sufficient?
> > Thoughts
>
> You mean we can record the timestamp now while inserting, not sure if
> it will add some more meaningful information than remote_commit_ts,
> but let's see what others think.
>
On rethinking, we can skip it. The commit-ts of both sides are enough.
> > 4)
> > Also, it makes sense if we have 'conflict_type' next to 'relid'. I
> > feel relid and conflict_type are primary columns and rest are related
> > details.
>
> Sure
>
> > 5)
> > Do we need table_schema, table_name when we have relid already? If we
> > want to retain these, we can name them as schemaname and relname to be
> > consistent with all other stats tables. IMO, then the order can be:
> > relid, schemaname, relname, conflcit_type and then the rest of the
> > details.
>
> Yeah this makes the table denormalized as we can fetch this
> information by joining with pg_class, but I think it might be better
> for readability, lets see what others think, for now I will reorder as
> suggested.
>
Okay, works for me if we want to keep these. I see that most of the
other statistics tables (pg_stat_all_indexes, pg_statio_all_tables,
pg_statio_all_sequences etc) that maintain a relid also retain the
names.
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-11-20 12:08 Dilip Kumar <dilipbalaut@gmail.com>
parent: Peter Smith <smithpb2250@gmail.com>
0 siblings, 2 replies; 912+ messages in thread
From: Dilip Kumar @ 2025-11-20 12:08 UTC (permalink / raw)
To: Peter Smith <smithpb2250@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Wed, Nov 19, 2025 at 7:01 AM Peter Smith <smithpb2250@gmail.com> wrote:
>
> Hi Dilip.
>
> I started to look at this thread. Here are some comments for patch v4-0001.
Thanks Peter for your review, worked on most of the comments for 0001
>
> =====
> GENERAL
>
> 1.
> There's some inconsistency in how this new table is called at different times :
> a) "conflict table"
> b) "conflict log table"
> c) "conflict log history table"
> d) "conflict history"
>
> My preference was (b). Making this consistent will have impacts on
> many macros, variables, comments, function names, etc.
Yeah even my preference is b) so used everywhere.
> ~~~
>
> 2.
> What about enhancements to description \dRs+ so the subscription
> conflict log table is displayed?
Done, I have displayed the conflict log table name, not sure shall we
display complete schema qualified name, if so we might need to join
with pg_namespace.
> ~~~
>
> 3.
> What about enhancements to the tab-complete code?
Done
> ======
> src/backend/commands/subscriptioncmds.c
>
> 4.
> #define SUBOPT_MAX_RETENTION_DURATION 0x00008000
> #define SUBOPT_LSN 0x00010000
> #define SUBOPT_ORIGIN 0x00020000
> +#define SUBOPT_CONFLICT_TABLE 0x00030000
>
> Bug? Shouldn't that be 0x00040000.
Yeah, fixed.
> ~~~
>
> 5.
> + char *conflicttable;
> XLogRecPtr lsn;
> } SubOpts;
>
> IMO 'conflicttable' looks too much like 'conflictable', which may
> cause some confusion on first reading.
Changed to conflictlogtable
> ~~~
>
> 6.
> +static void CreateConflictLogTable(Oid namespaceId, char *conflictrel);
> +static void DropConflictLogTable(Oid namespaceId, char *conflictrel);
>
> AFAIK it is more conventional for the static functions to be
> snake_case and the extern functions to use CamelCase. So these would
> be:
> - create_conflict_log_table
> - drop_conflict_log_table
Done
> ~~~
>
> CreateSubscription:
>
> 7.
> + /* If conflict log table name is given than create the table. */
> + if (opts.conflicttable)
> + CreateConflictLogTable(conflict_table_nspid, conflict_table);
> +
>
> typo: /If conflict/If a conflict/
>
> typo: "than"
Fixed
> ~~~
>
> AlterSubscription:
>
> 8.
> - SUBOPT_ORIGIN);
> + SUBOPT_ORIGIN |
> + SUBOPT_CONFLICT_TABLE);
>
> The line wrapping doesn't seem necessary.
Without wrapping it crosses 80 characters per line limit.
> ~~~
>
> 9.
> + replaces[Anum_pg_subscription_subconflictnspid - 1] = true;
> + replaces[Anum_pg_subscription_subconflicttable - 1] = true;
> +
> + CreateConflictLogTable(nspid, relname);
> + }
> +
>
> What are the rules regarding replacing one log table with a different
> log table for the same subscription? I didn't see anything about this
> scenario, nor any test cases.
Added test and updated the code as well, so if we set different log
table, we will drop the old and create new table, however if you set
the same table, just NOTICE will be issued and table will not be
created again.
> ~~~
>
> CreateConflictLogTable:
>
> 10.
> + /*
> + * Check if table with same name already present, if so report an error
> + * as currently we do not support user created table as conflict log
> + * table.
> + */
>
> Is the comment about "user-created table" strictly correct? e.g. Won't
> you encounter the same problem if there are 2 subscriptions trying to
> set the same-named conflict log table?
>
> SUGGESTION
> Report an error if the specified conflict log table already exists.
Done
> ~~~
>
> DropConflictLogTable:
>
> 11.
> + /*
> + * Drop conflict log table if exist, use if exists ensures the command
> + * won't error if the table is already gone.
> + */
>
> The reason for EXISTS was already mentioned in the function comment.
>
> SUGGESTION
> Drop the conflict log table if it exists.
Done
> ======
> src/backend/replication/logical/conflict.c
>
> 12.
> +static Datum TupleTableSlotToJsonDatum(TupleTableSlot *slot);
> +
> +static void InsertConflictLog(Relation rel,
> + TransactionId local_xid,
> + TimestampTz local_ts,
> + ConflictType conflict_type,
> + RepOriginId origin_id,
> + TupleTableSlot *searchslot,
> + TupleTableSlot *localslot,
> + TupleTableSlot *remoteslot);
>
> Same as earlier comment #6 -- isn't it conventional to use snake_case
> for the static function names?
Done
> ~~~
>
> TupleTableSlotToJsonDatum:
>
> 13.
> + * This would be a new internal helper function for logical replication
> + * Needs to handle various data types and potentially TOASTed data
>
> What's this comment about? Something doesn't look quite right.
Hmm, that's bad, fixed.
> ~~~
>
> InsertConflictLog:
>
> 14.
> + /* TODO: proper error code */
> + relid = get_relname_relid(relname, nspid);
> + if (!OidIsValid(relid))
> + elog(ERROR, "conflict log history table does not exists");
> + conflictrel = table_open(relid, RowExclusiveLock);
> + if (conflictrel == NULL)
> + elog(ERROR, "could not open conflict log history table");
>
> 14a.
> What's the TODO comment for? Are you going to replace these elogs?
replaced with ereport
> ~
>
> 14b.
> Typo: "does not exists"
fixed
> ~
>
> 14c.
> An unnecessary double-blank line follows this code fragment.
fixed
> ~~~
>
> 15.
> + /* Populate the values and nulls arrays */
> + attno = 0;
> + values[attno] = ObjectIdGetDatum(RelationGetRelid(rel));
> + attno++;
> +
> + if (TransactionIdIsValid(local_xid))
> + values[attno] = TransactionIdGetDatum(local_xid);
> + else
> + nulls[attno] = true;
> + attno++;
> +
> + if (TransactionIdIsValid(remote_xid))
> + values[attno] = TransactionIdGetDatum(remote_xid);
> + else
> + nulls[attno] = true;
> + attno++;
> +
> + values[attno] = LSNGetDatum(remote_final_lsn);
> + attno++;
> +
> + if (local_ts > 0)
> + values[attno] = TimestampTzGetDatum(local_ts);
> + else
> + nulls[attno] = true;
> + attno++;
> +
> + if (remote_commit_ts > 0)
> + values[attno] = TimestampTzGetDatum(remote_commit_ts);
> + else
> + nulls[attno] = true;
> + attno++;
> +
> + values[attno] =
> + CStringGetTextDatum(get_namespace_name(RelationGetNamespace(rel)));
> + attno++;
> +
> + values[attno] = CStringGetTextDatum(RelationGetRelationName(rel));
> + attno++;
> +
> + values[attno] = CStringGetTextDatum(ConflictTypeNames[conflict_type]);
> + attno++;
> +
> + if (origin_id != InvalidRepOriginId)
> + replorigin_by_oid(origin_id, true, &origin);
> +
> + if (origin != NULL)
> + values[attno] = CStringGetTextDatum(origin);
> + else
> + nulls[attno] = true;
> + attno++;
> +
> + if (replorigin_session_origin != InvalidRepOriginId)
> + replorigin_by_oid(replorigin_session_origin, true, &remote_origin);
> +
> + if (remote_origin != NULL)
> + values[attno] = CStringGetTextDatum(remote_origin);
> + else
> + nulls[attno] = true;
> + attno++;
> +
> + if (searchslot != NULL)
> + values[attno] = TupleTableSlotToJsonDatum(searchslot);
> + else
> + nulls[attno] = true;
> + attno++;
> +
> + if (localslot != NULL)
> + values[attno] = TupleTableSlotToJsonDatum(localslot);
> + else
> + nulls[attno] = true;
> + attno++;
> +
> + if (remoteslot != NULL)
> + values[attno] = TupleTableSlotToJsonDatum(remoteslot);
> + else
> + nulls[attno] = true;
> +
>
> 15a.
> It might be simpler to just post-increment that 'attno' in all the
> assignments and save a dozen lines of code:
> e.g. values[attno++] = ...
Yeah done that
> ~
>
> 15b.
> Also, put a sanity Assert check at the end, like:
> Assert(attno + 1 == MAX_CONFLICT_ATTR_NUM);
Done
>
> ======
> src/backend/utils/cache/lsyscache.c
>
> 16.
> + if (isnull)
> + {
> + ReleaseSysCache(tup);
> + return NULL;
> + }
> +
> + *nspid = subform->subconflictnspid;
> + relname = pstrdup(TextDatumGetCString(datum));
> +
> + ReleaseSysCache(tup);
> +
> + return relname;
>
> It would be tidier to have a single release/return by coding this
> slightly differently.
>
> SUGGESTION:
>
> char *relname = NULL;
> ...
> if (!isnull)
> {
> *nspid = subform->subconflictnspid;
> relname = pstrdup(TextDatumGetCString(datum));
> }
>
> ReleaseSysCache(tup);
> return relname;
Right, changed it.
> ======
> src/include/catalog/pg_subscription.h
>
> 17.
> + Oid subconflictnspid; /* Namespace Oid in which the conflict history
> + * table is created. */
>
> Would it be better to make these 2 new member names more alike, since
> they go together. e.g.
> confl_table_nspid
> confl_table_name
In pg_subscription.h all field follows same convention without "_" so
I have changed to
subconflictlognspid
subconflictlogtable
> ======
> src/include/replication/conflict.h
>
> 18.
> +#define MAX_CONFLICT_ATTR_NUM 15
>
> I felt this doesn't really belong here. Just define it atop/within the
> function InsertConflictLog()
Done
> ~~~
>
> 19.
> extern void InitConflictIndexes(ResultRelInfo *relInfo);
> +
> #endif
>
> Spurious whitespace change not needed for this patch.
Fixed
> ======
> src/test/regress/sql/subscription.sql
>
> 20.
> How about adding some more test scenarios:
> e.g.1. ALTER the conflict log table of some subscription that already has one
> e.g.2. Have multiple subscriptions that specify the same conflict log table
Added
Pending:
1) fixed review comments of 0002 and 0003
2) Need to add replica identity tuple instead of full tuple - reported by Shveta
3) Keeping the logs in case of outer transaction failure by moving log
insertion outside the main transaction - reported by Shveta
--
Regards,
Dilip Kumar
Google
Attachments:
[application/octet-stream] v5-0001-Add-configurable-conflict-log-history-table-for.patch (91.6K, ../../CAFiTN-uLGXyAQ-DS6kywbf0XiZu-Yr7oin-kUPQmdCwYLibDPw@mail.gmail.com/2-v5-0001-Add-configurable-conflict-log-history-table-for.patch)
download | inline diff:
From 791ed7e410619a5e9cc02f87039188d954c2a376 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 12 Nov 2025 10:43:19 +0530
Subject: [PATCH v5] Add configurable conflict log history table for Logical
Replication
This patch adds a feature to provide a structured, queryable record of all
logical replication conflicts. The current approach of logging conflicts as
plain text in the server logs makes it difficult to query, analyze, and
use for external monitoring and automation.
This patch addresses these limitations by introducing a configurable
conflict_log_table option in the CREATE SUBSCRIPTION command. Key design
decisions include:
User-Defined Table: The conflict log is stored in a user-managed table
rather than a system catalog.
Structured Data: Conflict details, including the original and remote tuples,
are stored in JSON columns, providing a flexible format to accommodate different
table schemas.
Comprehensive Information: The log table captures essential attributes such as
local and remote transaction IDs, LSNs, commit timestamps, and conflict type,
providing a complete record for post-mortem analysis.
This feature will make logical replication conflicts easier to monitor and manage,
significantly improving the overall resilience and operability of replication setups.
---
src/backend/commands/subscriptioncmds.c | 203 ++++++++++++++-
src/backend/replication/logical/conflict.c | 169 ++++++++++++
src/backend/replication/logical/worker.c | 10 +-
src/backend/utils/cache/lsyscache.c | 36 +++
src/bin/psql/describe.c | 8 +-
src/bin/psql/tab-complete.in.c | 6 +-
src/include/catalog/pg_subscription.h | 5 +
src/include/replication/worker_internal.h | 4 +
src/include/utils/lsyscache.h | 1 +
src/test/regress/expected/subscription.out | 284 ++++++++++++++-------
src/test/regress/sql/subscription.sql | 70 +++++
11 files changed, 700 insertions(+), 96 deletions(-)
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index 24b70234b35..2171cd36cd9 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -34,6 +34,7 @@
#include "commands/event_trigger.h"
#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "executor/spi.h"
#include "miscadmin.h"
#include "nodes/makefuncs.h"
#include "pgstat.h"
@@ -47,10 +48,12 @@
#include "storage/lmgr.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/guc.h"
#include "utils/lsyscache.h"
#include "utils/memutils.h"
#include "utils/pg_lsn.h"
+#include "utils/regproc.h"
#include "utils/syscache.h"
/*
@@ -75,6 +78,7 @@
#define SUBOPT_MAX_RETENTION_DURATION 0x00008000
#define SUBOPT_LSN 0x00010000
#define SUBOPT_ORIGIN 0x00020000
+#define SUBOPT_CONFLICT_LOG_TABLE 0x00040000
/* check if the 'val' has 'bits' set */
#define IsSet(val, bits) (((val) & (bits)) == (bits))
@@ -103,6 +107,7 @@ typedef struct SubOpts
bool retaindeadtuples;
int32 maxretention;
char *origin;
+ char *conflictlogtable;
XLogRecPtr lsn;
} SubOpts;
@@ -135,7 +140,8 @@ static List *merge_publications(List *oldpublist, List *newpublist, bool addpub,
static void ReportSlotConnectionError(List *rstates, Oid subid, char *slotname, char *err);
static void CheckAlterSubOption(Subscription *sub, const char *option,
bool slot_needs_update, bool isTopLevel);
-
+static void create_conflict_log_table(Oid namespaceId, char *conflictrel);
+static void drop_conflict_log_table(Oid namespaceId, char *conflictrel);
/*
* Common option parsing function for CREATE and ALTER SUBSCRIPTION commands.
@@ -191,6 +197,8 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->maxretention = 0;
if (IsSet(supported_opts, SUBOPT_ORIGIN))
opts->origin = pstrdup(LOGICALREP_ORIGIN_ANY);
+ if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_TABLE))
+ opts->conflictlogtable = NULL;
/* Parse options */
foreach(lc, stmt_options)
@@ -402,6 +410,15 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->specified_opts |= SUBOPT_LSN;
opts->lsn = lsn;
}
+ else if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_TABLE) &&
+ strcmp(defel->defname, "conflict_log_table") == 0)
+ {
+ if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_LOG_TABLE))
+ errorConflictingDefElem(defel, pstate);
+
+ opts->specified_opts |= SUBOPT_CONFLICT_LOG_TABLE;
+ opts->conflictlogtable = defGetString(defel);
+ }
else
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
@@ -599,6 +616,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bits32 supported_opts;
SubOpts opts = {0};
AclResult aclresult;
+ Oid conflictlogtable_nspid;
+ char *conflictlogtable;
/*
* Parse and check options.
@@ -612,7 +631,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
SUBOPT_DISABLE_ON_ERR | SUBOPT_PASSWORD_REQUIRED |
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
- SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN);
+ SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_TABLE);
parse_subscription_options(pstate, stmt->options, supported_opts, &opts);
/*
@@ -747,6 +767,25 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
values[Anum_pg_subscription_suborigin - 1] =
CStringGetTextDatum(opts.origin);
+ /*
+ * If a conflict log table name is specified, parse the schema and table
+ * name from the string. Store the namespace OID and the table name in
+ * the pg_subscription catalog tuple.
+ */
+ if (opts.conflictlogtable)
+ {
+ List *names = stringToQualifiedNameList(opts.conflictlogtable, NULL);
+
+ conflictlogtable_nspid =
+ QualifiedNameGetCreationNamespace(names, &conflictlogtable);
+ values[Anum_pg_subscription_subconflictlognspid - 1] =
+ ObjectIdGetDatum(conflictlogtable_nspid);
+ values[Anum_pg_subscription_subconflictlogtable - 1] =
+ CStringGetTextDatum(conflictlogtable);
+ }
+ else
+ nulls[Anum_pg_subscription_subconflictlogtable - 1] = true;
+
tup = heap_form_tuple(RelationGetDescr(rel), values, nulls);
/* Insert tuple into catalog. */
@@ -768,6 +807,10 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
ReplicationOriginNameForLogicalRep(subid, InvalidOid, originname, sizeof(originname));
replorigin_create(originname);
+ /* If a conflict log table name is given then create the table. */
+ if (opts.conflictlogtable)
+ create_conflict_log_table(conflictlogtable_nspid, conflictlogtable);
+
/*
* Connect to remote side to execute requested commands and fetch table
* and sequence info.
@@ -1410,7 +1453,8 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
SUBOPT_MAX_RETENTION_DURATION |
- SUBOPT_ORIGIN);
+ SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_TABLE);
parse_subscription_options(pstate, stmt->options,
supported_opts, &opts);
@@ -1665,6 +1709,59 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
origin = opts.origin;
}
+ if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_LOG_TABLE))
+ {
+ Oid nspid;
+ char *relname = NULL;
+ Oid old_nspid = InvalidOid;
+ char *old_relname = NULL;
+ List *names =
+ stringToQualifiedNameList(opts.conflictlogtable, NULL);
+
+ nspid = QualifiedNameGetCreationNamespace(names, &relname);
+ values[Anum_pg_subscription_subconflictlognspid - 1] =
+ ObjectIdGetDatum(nspid);
+ values[Anum_pg_subscription_subconflictlogtable - 1] =
+ CStringGetTextDatum(relname);
+
+ replaces[Anum_pg_subscription_subconflictlognspid - 1] = true;
+ replaces[Anum_pg_subscription_subconflictlogtable - 1] = true;
+
+ /*
+ * If the subscription already has the conflict log table
+ * set to the exact same name and namespace currently being
+ * specified, and that table exists, just give notice and
+ * skip creation.
+ */
+ old_relname =
+ get_subscription_conflict_log_table(subid, &old_nspid);
+ if (old_relname != NULL &&
+ strcmp(old_relname, relname) == 0 &&
+ old_nspid == nspid &&
+ OidIsValid(get_relname_relid(relname, nspid)))
+ {
+ char *nspname = get_namespace_name(nspid);
+
+ ereport(NOTICE,
+ (errmsg("skipping table creation because \"%s.%s\" is already set as conflict log table",
+ nspname, relname)));
+ pfree(nspname);
+ }
+ else
+ {
+ /*
+ * Drop the existing conflict log table if we are
+ * setting a new table.
+ */
+ if (old_relname)
+ drop_conflict_log_table(old_nspid, old_relname);
+ create_conflict_log_table(nspid, relname);
+ }
+
+ if (old_relname != NULL)
+ pfree(old_relname);
+ }
+
update_tuple = true;
break;
}
@@ -2027,6 +2124,8 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
Form_pg_subscription form;
List *rstates;
bool must_use_password;
+ Oid conflictlogtable_nsp = InvalidOid;
+ char *conflictlogtable = NULL;
/*
* The launcher may concurrently start a new worker for this subscription.
@@ -2110,6 +2209,20 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
ObjectAddressSet(myself, SubscriptionRelationId, subid);
EventTriggerSQLDropAddObject(&myself, true, true);
+ /* Fetch the conflict log table information. */
+ conflictlogtable =
+ get_subscription_conflict_log_table(subid, &conflictlogtable_nsp);
+
+ /*
+ * If the subscription had a conflict log table, drop it now. This happens
+ * before deleting the subscription tuple.
+ */
+ if (conflictlogtable)
+ {
+ drop_conflict_log_table(conflictlogtable_nsp, conflictlogtable);
+ pfree(conflictlogtable);
+ }
+
/* Remove the tuple from catalog. */
CatalogTupleDelete(rel, &tup->t_self);
@@ -3188,3 +3301,87 @@ defGetStreamingMode(DefElem *def)
def->defname)));
return LOGICALREP_STREAM_OFF; /* keep compiler quiet */
}
+
+/*
+ * Create conflict log table.
+ *
+ * The subscription owner becomes the owner of this table and has all
+ * privileges on it.
+ */
+static void
+create_conflict_log_table(Oid namespaceId, char *conflictrel)
+{
+ StringInfoData querybuf;
+
+ /* Report an error if the specified conflict log table already exists. */
+ if (OidIsValid(get_relname_relid(conflictrel, namespaceId)))
+ ereport(ERROR,
+ (errcode(ERRCODE_DUPLICATE_TABLE),
+ errmsg("cannot create conflict log table \"%s.%s\" because a table with that name already exists",
+ get_namespace_name(namespaceId), conflictrel),
+ errhint("Use a different name for the conflict log table or drop the existing table.")));
+
+ initStringInfo(&querybuf);
+
+ /* build and execute the CREATE TABLE query. */
+ appendStringInfo(&querybuf,
+ "CREATE TABLE %s.%s ("
+ "relid Oid,"
+ "schemaname TEXT,"
+ "relname TEXT,"
+ "conflict_type TEXT,"
+ "local_xid xid,"
+ "remote_xid xid,"
+ "remote_commit_lsn pg_lsn,"
+ "local_commit_ts TIMESTAMPTZ,"
+ "remote_commit_ts TIMESTAMPTZ,"
+ "local_origin TEXT,"
+ "remote_origin TEXT,"
+ "key_tuple JSON,"
+ "local_tuple JSON,"
+ "remote_tuple JSON)",
+ quote_identifier(get_namespace_name(namespaceId)),
+ quote_identifier(conflictrel));
+
+ if (SPI_connect() != SPI_OK_CONNECT)
+ elog(ERROR, "SPI_connect failed");
+
+ if (SPI_execute(querybuf.data, false, 0) != SPI_OK_UTILITY)
+ elog(ERROR, "SPI_exec failed: %s", querybuf.data);
+
+ if (SPI_finish() != SPI_OK_FINISH)
+ elog(ERROR, "SPI_finish failed");
+
+ pfree(querybuf.data);
+}
+
+/*
+ * Drop the conflict log table.
+ *
+ * This function uses SPI to execute DROP TABLE IF EXISTS.
+ * We use IF EXISTS to avoid errors if the user manually dropped it first.
+ */
+static void
+drop_conflict_log_table(Oid namespaceId, char *conflictrel)
+{
+ StringInfoData querybuf;
+
+ initStringInfo(&querybuf);
+
+ /* Drop the conflict log table if it exist. */
+ appendStringInfo(&querybuf,
+ "DROP TABLE IF EXISTS %s.%s",
+ quote_identifier(get_namespace_name(namespaceId)),
+ quote_identifier(conflictrel));
+
+ if (SPI_connect() != SPI_OK_CONNECT)
+ elog(ERROR, "SPI_connect failed");
+
+ if (SPI_execute(querybuf.data, false, 0) != SPI_OK_UTILITY)
+ elog(ERROR, "SPI_exec failed: %s", querybuf.data);
+
+ if (SPI_finish() != SPI_OK_FINISH)
+ elog(ERROR, "SPI_finish failed");
+
+ pfree(querybuf.data);
+}
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 16695592265..df0ffb8a931 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -15,13 +15,23 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/tableam.h"
+#include "access/table.h"
+#include "catalog/indexing.h"
+#include "catalog/namespace.h"
+#include "catalog/pg_namespace_d.h"
+#include "catalog/pg_type.h"
#include "executor/executor.h"
+#include "executor/spi.h"
#include "pgstat.h"
#include "replication/conflict.h"
#include "replication/worker_internal.h"
#include "storage/lmgr.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/lsyscache.h"
+#include "utils/pg_lsn.h"
static const char *const ConflictTypeNames[] = {
[CT_INSERT_EXISTS] = "insert_exists",
@@ -52,6 +62,16 @@ static char *build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
Oid indexoid);
static char *build_index_value_desc(EState *estate, Relation localrel,
TupleTableSlot *slot, Oid indexoid);
+static Datum tuple_table_slot_to_json_datum(TupleTableSlot *slot);
+
+static void insert_conflict_log(Relation rel,
+ TransactionId local_xid,
+ TimestampTz local_ts,
+ ConflictType conflict_type,
+ RepOriginId origin_id,
+ TupleTableSlot *searchslot,
+ TupleTableSlot *localslot,
+ TupleTableSlot *remoteslot);
/*
* Get the xmin and commit timestamp data (origin and timestamp) associated
@@ -112,6 +132,7 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
/* Form errdetail message by combining conflicting tuples information. */
foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
+ {
errdetail_apply_conflict(estate, relinfo, type, searchslot,
conflicttuple->slot, remoteslot,
conflicttuple->indexoid,
@@ -120,6 +141,15 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
conflicttuple->ts,
&err_detail);
+ /* Insert conflict details to log history table. */
+ insert_conflict_log(relinfo->ri_RelationDesc,
+ conflicttuple->xmin,
+ conflicttuple->ts, type,
+ conflicttuple->origin,
+ searchslot, conflicttuple->slot,
+ remoteslot);
+ }
+
pgstat_report_subscription_conflict(MySubscription->oid, type);
ereport(elevel,
@@ -525,3 +555,142 @@ build_index_value_desc(EState *estate, Relation localrel, TupleTableSlot *slot,
return index_value;
}
+
+/*
+ * tuple_table_slot_to_json_datum
+ *
+ * Helper function to convert a TupleTableSlot to Jsonb.
+ */
+static Datum
+tuple_table_slot_to_json_datum(TupleTableSlot *slot)
+{
+ HeapTuple tuple = ExecCopySlotHeapTuple(slot);
+ Datum datum = heap_copy_tuple_as_datum(tuple, slot->tts_tupleDescriptor);
+ Datum json;
+
+ if (TupIsNull(slot))
+ return 0;
+
+ json = DirectFunctionCall1(row_to_json, datum);
+ heap_freetuple(tuple);
+
+ return json;
+}
+
+/*
+ * insert_conflict_log
+ *
+ * Insert details about a logical replication conflict to a conflict log table.
+ */
+static void
+insert_conflict_log(Relation rel, TransactionId local_xid,
+ TimestampTz local_ts, ConflictType conflict_type,
+ RepOriginId origin_id, TupleTableSlot *searchslot,
+ TupleTableSlot *localslot, TupleTableSlot *remoteslot)
+{
+#define MAX_CONFLICT_ATTR_NUM 15
+ Datum values[MAX_CONFLICT_ATTR_NUM];
+ bool nulls[MAX_CONFLICT_ATTR_NUM];
+ Oid nspid;
+ Oid relid;
+ Relation conflictrel = NULL;
+ int attno;
+ int options = HEAP_INSERT_NO_LOGICAL;
+ char *relname;
+ char *origin = NULL;
+ char *remote_origin = NULL;
+ HeapTuple tup;
+
+ /* If conflict log table is not set for the subscription just return. */
+ relname = get_subscription_conflict_log_table(
+ MyLogicalRepWorker->subid, &nspid);
+ if (relname == NULL)
+ return;
+
+ relid = get_relname_relid(relname, nspid);
+ if (OidIsValid(relid))
+ conflictrel = table_open(relid, RowExclusiveLock);
+
+ /* Conflict log table is dropped or not accessible. */
+ if (conflictrel == NULL)
+ ereport(ERROR,
+ (errcode(ERRCODE_UNDEFINED_TABLE),
+ errmsg("conflict log table \"%s.%s\" does not exist",
+ get_namespace_name(nspid), relname)));
+
+ /* Initialize values and nulls arrays. */
+ memset(values, 0, sizeof(Datum) * MAX_CONFLICT_ATTR_NUM);
+ memset(nulls, 0, sizeof(bool) * MAX_CONFLICT_ATTR_NUM);
+
+ /* Populate the values and nulls arrays. */
+ attno = 0;
+ values[attno++] = ObjectIdGetDatum(RelationGetRelid(rel));
+
+ values[attno++] =
+ CStringGetTextDatum(get_namespace_name(RelationGetNamespace(rel)));
+
+ values[attno++] = CStringGetTextDatum(RelationGetRelationName(rel));
+
+ values[attno++] = CStringGetTextDatum(ConflictTypeNames[conflict_type]);
+
+ if (TransactionIdIsValid(local_xid))
+ values[attno++] = TransactionIdGetDatum(local_xid);
+ else
+ nulls[attno++] = true;
+
+ if (TransactionIdIsValid(remote_xid))
+ values[attno++] = TransactionIdGetDatum(remote_xid);
+ else
+ nulls[attno++] = true;
+
+ values[attno++] = LSNGetDatum(remote_final_lsn);
+
+ if (local_ts > 0)
+ values[attno++] = TimestampTzGetDatum(local_ts);
+ else
+ nulls[attno++] = true;
+
+ if (remote_commit_ts > 0)
+ values[attno++] = TimestampTzGetDatum(remote_commit_ts);
+ else
+ nulls[attno++] = true;
+
+ if (origin_id != InvalidRepOriginId)
+ replorigin_by_oid(origin_id, true, &origin);
+
+ if (origin != NULL)
+ values[attno++] = CStringGetTextDatum(origin);
+ else
+ nulls[attno++] = true;
+
+ if (replorigin_session_origin != InvalidRepOriginId)
+ replorigin_by_oid(replorigin_session_origin, true, &remote_origin);
+
+ if (remote_origin != NULL)
+ values[attno++] = CStringGetTextDatum(remote_origin);
+ else
+ nulls[attno++] = true;
+
+ if (searchslot != NULL)
+ values[attno++] = tuple_table_slot_to_json_datum(searchslot);
+ else
+ nulls[attno++] = true;
+
+ if (localslot != NULL)
+ values[attno++] = tuple_table_slot_to_json_datum(localslot);
+ else
+ nulls[attno++] = true;
+
+ if (remoteslot != NULL)
+ values[attno++] = tuple_table_slot_to_json_datum(remoteslot);
+ else
+ nulls[attno++] = true;
+
+ Assert(attno + 1 == MAX_CONFLICT_ATTR_NUM);
+
+ tup = heap_form_tuple(RelationGetDescr(conflictrel), values, nulls);
+ heap_insert(conflictrel, tup, GetCurrentCommandId(true), options, NULL);
+ table_close(conflictrel, RowExclusiveLock);
+
+ pfree(relname);
+}
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 93970c6af29..e6c02685ec9 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -482,7 +482,9 @@ static bool MySubscriptionValid = false;
static List *on_commit_wakeup_workers_subids = NIL;
bool in_remote_transaction = false;
-static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+TransactionId remote_xid = InvalidTransactionId;
+TimestampTz remote_commit_ts = 0;
/* fields valid only when processing streamed transaction */
static bool in_streamed_transaction = false;
@@ -1219,6 +1221,8 @@ apply_handle_begin(StringInfo s)
set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
remote_final_lsn = begin_data.final_lsn;
+ remote_commit_ts = begin_data.committime;
+ remote_xid = begin_data.xid;
maybe_start_skipping_changes(begin_data.final_lsn);
@@ -1745,6 +1749,10 @@ apply_handle_stream_start(StringInfo s)
/* extract XID of the top-level transaction */
stream_xid = logicalrep_read_stream_start(s, &first_segment);
+ remote_xid = stream_xid;
+ remote_final_lsn = InvalidXLogRecPtr;
+ remote_commit_ts = 0;
+
if (!TransactionIdIsValid(stream_xid))
ereport(ERROR,
(errcode(ERRCODE_PROTOCOL_VIOLATION),
diff --git a/src/backend/utils/cache/lsyscache.c b/src/backend/utils/cache/lsyscache.c
index fa7cd7e06a7..083cc42a4a6 100644
--- a/src/backend/utils/cache/lsyscache.c
+++ b/src/backend/utils/cache/lsyscache.c
@@ -3881,3 +3881,39 @@ get_subscription_name(Oid subid, bool missing_ok)
return subname;
}
+
+/*
+ * get_subscription_conflict_log_table
+ *
+ * Get conflict log table name and namespace id from subscription.
+ */
+char *
+get_subscription_conflict_log_table(Oid subid, Oid *nspid)
+{
+ HeapTuple tup;
+ Datum datum;
+ bool isnull;
+ char *relname = NULL;
+ Form_pg_subscription subform;
+
+ tup = SearchSysCache1(SUBSCRIPTIONOID, ObjectIdGetDatum(subid));
+
+ if (!HeapTupleIsValid(tup))
+ return NULL;
+
+ subform = (Form_pg_subscription) GETSTRUCT(tup);
+
+ /* Get conflict log table name. */
+ datum = SysCacheGetAttr(SUBSCRIPTIONOID,
+ tup,
+ Anum_pg_subscription_subconflictlogtable,
+ &isnull);
+ if (!isnull)
+ {
+ *nspid = subform->subconflictlognspid;
+ relname = pstrdup(TextDatumGetCString(datum));
+ }
+
+ ReleaseSysCache(tup);
+ return relname;
+}
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index 36f24502842..c18ec248e5d 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -6806,7 +6806,7 @@ describeSubscriptions(const char *pattern, bool verbose)
printQueryOpt myopt = pset.popt;
static const bool translate_columns[] = {false, false, false, false,
false, false, false, false, false, false, false, false, false, false,
- false, false, false, false};
+ false, false, false, false, false};
if (pset.sversion < 100000)
{
@@ -6900,6 +6900,12 @@ describeSubscriptions(const char *pattern, bool verbose)
appendPQExpBuffer(&buf,
", subskiplsn AS \"%s\"\n",
gettext_noop("Skip LSN"));
+
+ /* Conflict log table is only supported in v19 and higher */
+ if (pset.sversion >= 190000)
+ appendPQExpBuffer(&buf,
+ ", subconflictlogtable AS \"%s\"\n",
+ gettext_noop("Conflict log table"));
}
/* Only display subscriptions in current database. */
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 51806597037..28c75ab84bf 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -2344,8 +2344,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("(", "PUBLICATION");
/* ALTER SUBSCRIPTION <name> SET ( */
else if (Matches("ALTER", "SUBSCRIPTION", MatchAny, MatchAnyN, "SET", "("))
- COMPLETE_WITH("binary", "disable_on_error", "failover",
- "max_retention_duration", "origin",
+ COMPLETE_WITH("binary", "conflict_log_table", "disable_on_error",
+ "failover", "max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
"synchronous_commit", "two_phase");
@@ -3814,7 +3814,7 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("WITH (");
/* Complete "CREATE SUBSCRIPTION <name> ... WITH ( <opt>" */
else if (Matches("CREATE", "SUBSCRIPTION", MatchAnyN, "WITH", "("))
- COMPLETE_WITH("binary", "connect", "copy_data", "create_slot",
+ COMPLETE_WITH("binary", "connect", "conflict_log_table", "copy_data", "create_slot",
"disable_on_error", "enabled", "failover",
"max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index 55cb9b1eefa..f4526c15ec3 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -80,6 +80,8 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
bool subretaindeadtuples; /* True if dead tuples useful for
* conflict detection are retained */
+ Oid subconflictlognspid; /* Namespace Oid in which the conflict
+ * log table is created. */
int32 submaxretention; /* The maximum duration (in milliseconds)
* for which information useful for
@@ -105,6 +107,9 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
/* Only publish data originating from the specified origin */
text suborigin BKI_DEFAULT(LOGICALREP_ORIGIN_ANY);
+
+ /* Conflict log table name if specified */
+ text subconflictlogtable;
#endif
} FormData_pg_subscription;
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index f081619f151..314ac5dc746 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -256,6 +256,10 @@ extern PGDLLIMPORT bool InitializingApplyWorker;
extern PGDLLIMPORT List *table_states_not_ready;
+extern XLogRecPtr remote_final_lsn;
+extern TimestampTz remote_commit_ts;
+extern TransactionId remote_xid;
+
extern void logicalrep_worker_attach(int slot);
extern LogicalRepWorker *logicalrep_worker_find(LogicalRepWorkerType wtype,
Oid subid, Oid relid,
diff --git a/src/include/utils/lsyscache.h b/src/include/utils/lsyscache.h
index 50fb149e9ac..3bebf04bf51 100644
--- a/src/include/utils/lsyscache.h
+++ b/src/include/utils/lsyscache.h
@@ -210,6 +210,7 @@ extern Oid get_publication_oid(const char *pubname, bool missing_ok);
extern char *get_publication_name(Oid pubid, bool missing_ok);
extern Oid get_subscription_oid(const char *subname, bool missing_ok);
extern char *get_subscription_name(Oid subid, bool missing_ok);
+extern char *get_subscription_conflict_log_table(Oid subid, Oid *nspid);
#define type_is_array(typid) (get_element_type(typid) != InvalidOid)
/* type_is_array_domain accepts both plain arrays and domains over arrays */
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index 327d1e7731f..626ab01f448 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -116,18 +116,18 @@ CREATE SUBSCRIPTION regress_testsub4 CONNECTION 'dbname=regress_doesnotexist' PU
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub4 SET (origin = any);
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
DROP SUBSCRIPTION regress_testsub3;
@@ -145,10 +145,10 @@ ALTER SUBSCRIPTION regress_testsub CONNECTION 'foobar';
ERROR: invalid connection string syntax: missing "=" after "foobar" in connection info string
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET PUBLICATION testpub2, testpub3 WITH (refresh = false);
@@ -157,10 +157,10 @@ ALTER SUBSCRIPTION regress_testsub SET (slot_name = 'newname');
ALTER SUBSCRIPTION regress_testsub SET (password_required = false);
ALTER SUBSCRIPTION regress_testsub SET (run_as_owner = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (password_required = true);
@@ -176,10 +176,10 @@ ERROR: unrecognized subscription parameter: "create_slot"
-- ok
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/12345');
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345 |
(1 row)
-- ok - with lsn = NONE
@@ -188,10 +188,10 @@ ALTER SUBSCRIPTION regress_testsub SKIP (lsn = NONE);
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/0');
ERROR: invalid WAL location (LSN): 0/0
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 |
(1 row)
BEGIN;
@@ -223,10 +223,10 @@ ALTER SUBSCRIPTION regress_testsub_foo SET (synchronous_commit = foobar);
ERROR: invalid value for parameter "synchronous_commit": "foobar"
HINT: Available values: local, remote_write, remote_apply, on, off.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
----------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+---------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000 |
(1 row)
-- rename back to keep the rest simple
@@ -255,19 +255,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (binary = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -279,27 +279,27 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = parallel);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- fail - publication already exists
@@ -314,10 +314,10 @@ ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refr
ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refresh = false);
ERROR: publication "testpub1" is already in subscription "regress_testsub"
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- fail - publication used more than once
@@ -332,10 +332,10 @@ ERROR: publication "testpub3" is not in subscription "regress_testsub"
-- ok - delete publications
ALTER SUBSCRIPTION regress_testsub DROP PUBLICATION testpub1, testpub2 WITH (refresh = false);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -371,19 +371,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- we can alter streaming when two_phase enabled
ALTER SUBSCRIPTION regress_testsub SET (streaming = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -393,10 +393,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -409,18 +409,18 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (disable_on_error = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -433,10 +433,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -450,19 +450,19 @@ NOTICE: max_retention_duration is ineffective when retain_dead_tuples is disabl
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- ok
ALTER SUBSCRIPTION regress_testsub SET (max_retention_duration = 0);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -517,6 +517,114 @@ COMMIT;
-- ok, owning it is enough for this stuff
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG HISTORY TABLE TESTS
+--
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+-- fail - conflict_log_table specified when table already exists
+CREATE TABLE public.regress_conflict_log_temp (id int);
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log_temp');
+ERROR: cannot create conflict log table "public.regress_conflict_log_temp" because a table with that name already exists
+HINT: Use a different name for the conflict log table or drop the existing table.
+DROP TABLE public.regress_conflict_log_temp;
+-- ok - conflict_log_table creation with CREATE SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- check metadata in pg_subscription
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname | subconflictlogtable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test1 | regress_conflict_log1 | t
+(1 row)
+
+-- check if the table exists and has the correct schema (15 columns)
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attnum > 0;
+ count
+-------
+ 14
+(1 row)
+
+-- check a specific column type (e.g., key_tuple should be JSON)
+SELECT format_type(atttypid, atttypmod) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attname = 'key_tuple';
+ format_type
+-------------
+ json
+(1 row)
+
+-- ok - adding conflict_log_table with ALTER SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log2');
+-- check metadata after ALTER
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogtable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test2 | regress_conflict_log2 | t
+(1 row)
+
+-- ok - change the conlfict log table name for existing subscription already had old table
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log3');
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogtable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test2 | regress_conflict_log3 | t
+(1 row)
+
+-- check new table should be created and old should be dropped
+SELECT count(*) FROM pg_class WHERE relname = 'regress_conflict_log2';
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log3'::regclass AND attnum > 0;
+ count
+-------
+ 14
+(1 row)
+
+-- ok (NOTICE) - try to set the conflict log table which is used by same subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log3');
+NOTICE: skipping table creation because "public.regress_conflict_log3" is already set as conflict log table
+-- fail - try to use the conflict log table being used by some other subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log1');
+ERROR: cannot create conflict log table "public.regress_conflict_log1" because a table with that name already exists
+HINT: Use a different name for the conflict log table or drop the existing table.
+-- ok - dropping subscription also drops the log table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the table was dropped
+SELECT to_regclass('public.regress_conflict_log1');
+ to_regclass
+-------------
+
+(1 row)
+
+-- ok - dropping subscription when the log table was manually dropped first
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+DROP TABLE public.regress_conflict_log1;
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+NOTICE: table "regress_conflict_log1" does not exist, skipping
+-- should return NULL, meaning the subscription was dropped successfully
+SELECT subname FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname
+---------
+(0 rows)
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
RESET SESSION AUTHORIZATION;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index ef0c298d2df..d5d32c9600c 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -365,6 +365,76 @@ COMMIT;
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG HISTORY TABLE TESTS
+--
+
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+
+-- fail - conflict_log_table specified when table already exists
+CREATE TABLE public.regress_conflict_log_temp (id int);
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log_temp');
+DROP TABLE public.regress_conflict_log_temp;
+
+-- ok - conflict_log_table creation with CREATE SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+
+-- check metadata in pg_subscription
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- check if the table exists and has the correct schema (15 columns)
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attnum > 0;
+
+-- check a specific column type (e.g., key_tuple should be JSON)
+SELECT format_type(atttypid, atttypmod) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attname = 'key_tuple';
+
+-- ok - adding conflict_log_table with ALTER SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log2');
+
+-- check metadata after ALTER
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- ok - change the conlfict log table name for existing subscription already had old table
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log3');
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- check new table should be created and old should be dropped
+SELECT count(*) FROM pg_class WHERE relname = 'regress_conflict_log2';
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log3'::regclass AND attnum > 0;
+
+-- ok (NOTICE) - try to set the conflict log table which is used by same subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log3');
+
+-- fail - try to use the conflict log table being used by some other subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log1');
+
+-- ok - dropping subscription also drops the log table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the table was dropped
+SELECT to_regclass('public.regress_conflict_log1');
+
+-- ok - dropping subscription when the log table was manually dropped first
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+DROP TABLE public.regress_conflict_log1;
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the subscription was dropped successfully
+SELECT subname FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+
RESET SESSION AUTHORIZATION;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
--
2.49.0
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-11-21 02:05 Peter Smith <smithpb2250@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
1 sibling, 0 replies; 912+ messages in thread
From: Peter Smith @ 2025-11-21 02:05 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Thanks for addressing all my previous review comment of v4.
Here are some more comments for the latest patch v5-0001.
======
GENERAL
1.
There are still a couple of place remainig where this new table was
not consistent called a "Conflict Log Table" (e.g. search for
"history")
e.g. Subject: [PATCH v5] Add configurable conflict log history table
for Logical Replication
e.g. + /* Insert conflict details to log history table. */
e.g. +-- CONFLICT LOG HISTORY TABLE TESTS
~~~
2.
Is automatically dropping the log tables always what the user might
want to happen? Maybe someone want them lying around afterwards for
later analysis -- I don't really know the answer; Just wondering if
this is (a) good to be tidy or (b) bad to remove user flexibility. Or
maybe the answer is leave if but make sure to add more documentation
to say "if you are going to want to do some post analysis then be sure
to copy this table data before it gets automatically dropped".
======
Commit message.
3.
User-Defined Table: The conflict log is stored in a user-managed table
rather than a system catalog.
~
I felt "User-defined" makes it sound like the user does CREATE TABLE
themselves and has some control over the schema. Maybe say
"User-Managed Table:" instead?
======
src/backend/commands/subscriptioncmds.c
4.
#define SUBOPT_LSN 0x00010000
#define SUBOPT_ORIGIN 0x00020000
+#define SUBOPT_CONFLICT_LOG_TABLE 0x00040000
Whitespace alignment.
~~~
AlterSubscription:
5.
+ values[Anum_pg_subscription_subconflictlognspid - 1] =
+ ObjectIdGetDatum(nspid);
+ values[Anum_pg_subscription_subconflictlogtable - 1] =
+ CStringGetTextDatum(relname);
+
+ replaces[Anum_pg_subscription_subconflictlognspid - 1] = true;
+ replaces[Anum_pg_subscription_subconflictlogtable - 1] = true;
Something feels back-to-front, because if the same clt is being
re-used (like the NOTICE part taht follows) then why do you need to
reassign and say replaces[] = true here?
~~~
6.
+ /*
+ * If the subscription already has the conflict log table
+ * set to the exact same name and namespace currently being
+ * specified, and that table exists, just give notice and
+ * skip creation.
+ */
Is there a simpler way to say the same thing?
SUGGESTION
If the subscription already uses this conflict log table and it
exists, just issue a notice.
~~~
7.
+ ereport(NOTICE,
+ (errmsg("skipping table creation because \"%s.%s\" is already set as
conflict log table",
+ nspname, relname)));
I wasn't sure you need to say "skipping table creation because"... it
seems kind of internal details. How about just:
\"%s.%s\" is already in use as the conflict log table for this subscription
~~~
8.
+ /*
+ * Drop the existing conflict log table if we are
+ * setting a new table.
+ */
The comment didn't feel right by implying there is something to drop.
SUGGESTION
Create the conflict log table after dropping any pre-existing one.
~~~
drop_conflict_log_table:
9.
+ /* Drop the conflict log table if it exist. */
typo: /exist./exists./
======
src/backend/replication/logical/conflict.c
10.
+static Datum
+tuple_table_slot_to_json_datum(TupleTableSlot *slot)
+{
+ HeapTuple tuple = ExecCopySlotHeapTuple(slot);
+ Datum datum = heap_copy_tuple_as_datum(tuple, slot->tts_tupleDescriptor);
+ Datum json;
+
+ if (TupIsNull(slot))
+ return 0;
+
+ json = DirectFunctionCall1(row_to_json, datum);
+ heap_freetuple(tuple);
+
+ return json;
+}
Bug? Shouldn't that TupIsNull(slot) check *precede* using that slot
for the tuple/datum assignments?
~~~
insert_conflict_log:
11.
+ Datum values[MAX_CONFLICT_ATTR_NUM];
+ bool nulls[MAX_CONFLICT_ATTR_NUM];
+ Oid nspid;
+ Oid relid;
+ Relation conflictrel = NULL;
+ int attno;
+ int options = HEAP_INSERT_NO_LOGICAL;
+ char *relname;
+ char *origin = NULL;
+ char *remote_origin = NULL;
+ HeapTuple tup;
I felt some of these var names can be confusing:
11A.
e.g. "conflictlogrel" (instead of 'conflictrel') would emphasise this
is the rel of the log file, not the rel that encountered a conflict.
~
11B.
Similarly, maybe 'relname' could be 'conflictlogtable', which is also
what it was called elsewhere.
~
11C.
AFAICT, the 'relid' is really the relid of the conflict log. So, maybe
name it as it 'confliglogreid', otherwise it seems confusing when
there is already parameter called 'rel' that is unrelated to thia
'relid'.
~~~
12.
+ if (searchslot != NULL)
+ values[attno++] = tuple_table_slot_to_json_datum(searchslot);
+ else
+ nulls[attno++] = true;
+
+ if (localslot != NULL)
+ values[attno++] = tuple_table_slot_to_json_datum(localslot);
+ else
+ nulls[attno++] = true;
+
+ if (remoteslot != NULL)
+ values[attno++] = tuple_table_slot_to_json_datum(remoteslot);
+ else
+ nulls[attno++] = true;
That function tuple_table_slot_to_json_datum() has potential to return
0. Is that something that needs checking, so you can assign nulls[] =
true?
======
src/backend/replication/logical/worker.c
13.
+char *
+get_subscription_conflict_log_table(Oid subid, Oid *nspid)
+{
+ HeapTuple tup;
+ Datum datum;
+ bool isnull;
+ char *relname = NULL;
+ Form_pg_subscription subform;
+
+ tup = SearchSysCache1(SUBSCRIPTIONOID, ObjectIdGetDatum(subid));
+
+ if (!HeapTupleIsValid(tup))
+ return NULL;
+
+ subform = (Form_pg_subscription) GETSTRUCT(tup);
+
+ /* Get conflict log table name. */
+ datum = SysCacheGetAttr(SUBSCRIPTIONOID,
+ tup,
+ Anum_pg_subscription_subconflictlogtable,
+ &isnull);
+ if (!isnull)
+ {
+ *nspid = subform->subconflictlognspid;
+ relname = pstrdup(TextDatumGetCString(datum));
+ }
+
+ ReleaseSysCache(tup);
+ return relname;
+}
You could consider assigning *nspid = InvalidOid when 'isnull' is
true, so then you don't have to rely on the caller pre-assigning a
default sane value. YMMV.
======
src/bin/psql/tab-complete.in.c
14.
- COMPLETE_WITH("binary", "connect", "copy_data", "create_slot",
+ COMPLETE_WITH("binary", "connect", "conflict_log_table",
"copy_data", "create_slot",
'conflict_log_table' comes before 'connect' alphabetically.
======
src/test/regress/sql/subscription.sql
15.
+-- ok - change the conlfict log table name for existing subscription
already had old table
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table =
'public.regress_conflict_log3');
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT
oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
typos in comment.
- /conlfict/conlflict/
- /for existing subscription already had old table/for an existing
subscription that already had one/
~~~
16.
+-- check new table should be created and old should be dropped
SUGGESTION
check the new table was created and the old table was dropped
~~~
17.
+-- ok (NOTICE) - try to set the conflict log table which is used by
same subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table =
'public.regress_conflict_log3');
+
+-- fail - try to use the conflict log table being used by some other
subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table =
'public.regress_conflict_log1');
Make those 2 comment more alike:
SUGGESTIONS
-- ok (NOTICE) - set conflict_log_table to one already used by this subscription
...
-- fail - set conflict_log_table to one already used by a different subscription
~~~
18.
Missing tests for describe \dRs+.
e.g. there are already dozens of \dRs+ examples where there is no clt
assigned, but I did not see any tests where the clt *is* assigned.
======
Kind Regards,
Peter Smith.
Fujitsu Australia
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-11-24 06:21 Dilip Kumar <dilipbalaut@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-11-24 06:21 UTC (permalink / raw)
To: Peter Smith <smithpb2250@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, Nov 20, 2025 at 5:38 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
I was working on these pending items, there is something where I got
stuck, I am exploring this more but would like to share the problem.
> 2) Need to add replica identity tuple instead of full tuple - reported by Shveta
I have worked on fixing this along with other comments by Peter, now
we can see only RI tuple is inserted as part of the key_tuple, IMHO
lets keep the name as key tuple as it will use the primary key or
unique key if no explicit replicate identity is set, thoughts?
postgres[3048044]=# select * from myschema.conflict_log_history2;
relid | schemaname | relname | conflict_type | local_xid |
remote_xid | remote_commit_lsn | local_commit_ts |
remote_commit_ts | local_o
rigin | remote_origin | key_tuple | local_tuple | remote_tuple
-------+------------+---------+-----------------------+-----------+------------+-------------------+-------------------------------+-------------------------------+--------
------+---------------+-----------+----------------+----------------
16385 | public | test | update_origin_differs | 765 |
759 | 0/0174F2E8 | 2025-11-24 06:16:50.468263+00 |
2025-11-24 06:16:55.483507+00 |
| pg_16396 | {"a":1} | {"a":1,"b":10} | {"a":1,"b":20}
Now pending work status
1) fixed review comments of 0002 and 0003 - Pending
2) Need to add replica identity tuple instead of full tuple -- Done
3) Keeping the logs in case of outer transaction failure by moving log
insertion outside the main transaction - reported by Shveta - Pending
4) Run pgindent -- planning to do it after we complete the first level
of review - Pending
5) Subscription test cases for logging the actual conflicts - Pending
--
Regards,
Dilip Kumar
Google
Attachments:
[application/octet-stream] v6-0001-Add-configurable-conflict-log-table-for-Logical-R.patch (99.2K, ../../CAFiTN-vGGMESm4K6GnyowdTfdqirGvx8beZDfaAXKqDTkQ6UpQ@mail.gmail.com/2-v6-0001-Add-configurable-conflict-log-table-for-Logical-R.patch)
download | inline diff:
From 8ec5cf4cbfc253e51cc04df7c43a6915581a4bcc Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 12 Nov 2025 10:43:19 +0530
Subject: [PATCH v6] Add configurable conflict log table for Logical
Replication
This patch adds a feature to provide a structured, queryable record of all
logical replication conflicts. The current approach of logging conflicts as
plain text in the server logs makes it difficult to query, analyze, and
use for external monitoring and automation.
This patch addresses these limitations by introducing a configurable
conflict_log_table option in the CREATE SUBSCRIPTION command. Key design
decisions include:
User-Managed Table: The conflict log is stored in a user-managed table
rather than a system catalog.
Structured Data: Conflict details, including the original and remote tuples,
are stored in JSON columns, providing a flexible format to accommodate different
table schemas.
Comprehensive Information: The log table captures essential attributes such as
local and remote transaction IDs, LSNs, commit timestamps, and conflict type,
providing a complete record for post-mortem analysis.
This feature will make logical replication conflicts easier to monitor and manage,
significantly improving the overall resilience and operability of replication setups.
---
src/backend/commands/subscriptioncmds.c | 203 +++++++++++++-
src/backend/replication/logical/conflict.c | 294 ++++++++++++++++++--
src/backend/replication/logical/worker.c | 10 +-
src/backend/utils/cache/lsyscache.c | 38 +++
src/bin/psql/describe.c | 8 +-
src/bin/psql/tab-complete.in.c | 8 +-
src/include/catalog/pg_subscription.h | 5 +
src/include/replication/worker_internal.h | 4 +
src/include/utils/lsyscache.h | 1 +
src/test/regress/expected/subscription.out | 299 +++++++++++++++------
src/test/regress/sql/subscription.sql | 73 +++++
11 files changed, 823 insertions(+), 120 deletions(-)
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index 24b70234b35..fa7d96b95cb 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -34,6 +34,7 @@
#include "commands/event_trigger.h"
#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "executor/spi.h"
#include "miscadmin.h"
#include "nodes/makefuncs.h"
#include "pgstat.h"
@@ -47,10 +48,12 @@
#include "storage/lmgr.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/guc.h"
#include "utils/lsyscache.h"
#include "utils/memutils.h"
#include "utils/pg_lsn.h"
+#include "utils/regproc.h"
#include "utils/syscache.h"
/*
@@ -75,6 +78,7 @@
#define SUBOPT_MAX_RETENTION_DURATION 0x00008000
#define SUBOPT_LSN 0x00010000
#define SUBOPT_ORIGIN 0x00020000
+#define SUBOPT_CONFLICT_LOG_TABLE 0x00040000
/* check if the 'val' has 'bits' set */
#define IsSet(val, bits) (((val) & (bits)) == (bits))
@@ -103,6 +107,7 @@ typedef struct SubOpts
bool retaindeadtuples;
int32 maxretention;
char *origin;
+ char *conflictlogtable;
XLogRecPtr lsn;
} SubOpts;
@@ -135,7 +140,8 @@ static List *merge_publications(List *oldpublist, List *newpublist, bool addpub,
static void ReportSlotConnectionError(List *rstates, Oid subid, char *slotname, char *err);
static void CheckAlterSubOption(Subscription *sub, const char *option,
bool slot_needs_update, bool isTopLevel);
-
+static void create_conflict_log_table(Oid namespaceId, char *conflictrel);
+static void drop_conflict_log_table(Oid namespaceId, char *conflictrel);
/*
* Common option parsing function for CREATE and ALTER SUBSCRIPTION commands.
@@ -191,6 +197,8 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->maxretention = 0;
if (IsSet(supported_opts, SUBOPT_ORIGIN))
opts->origin = pstrdup(LOGICALREP_ORIGIN_ANY);
+ if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_TABLE))
+ opts->conflictlogtable = NULL;
/* Parse options */
foreach(lc, stmt_options)
@@ -402,6 +410,15 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->specified_opts |= SUBOPT_LSN;
opts->lsn = lsn;
}
+ else if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_TABLE) &&
+ strcmp(defel->defname, "conflict_log_table") == 0)
+ {
+ if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_LOG_TABLE))
+ errorConflictingDefElem(defel, pstate);
+
+ opts->specified_opts |= SUBOPT_CONFLICT_LOG_TABLE;
+ opts->conflictlogtable = defGetString(defel);
+ }
else
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
@@ -599,6 +616,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bits32 supported_opts;
SubOpts opts = {0};
AclResult aclresult;
+ Oid conflictlogtable_nspid;
+ char *conflictlogtable;
/*
* Parse and check options.
@@ -612,7 +631,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
SUBOPT_DISABLE_ON_ERR | SUBOPT_PASSWORD_REQUIRED |
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
- SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN);
+ SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_TABLE);
parse_subscription_options(pstate, stmt->options, supported_opts, &opts);
/*
@@ -747,6 +767,25 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
values[Anum_pg_subscription_suborigin - 1] =
CStringGetTextDatum(opts.origin);
+ /*
+ * If a conflict log table name is specified, parse the schema and table
+ * name from the string. Store the namespace OID and the table name in
+ * the pg_subscription catalog tuple.
+ */
+ if (opts.conflictlogtable)
+ {
+ List *names = stringToQualifiedNameList(opts.conflictlogtable, NULL);
+
+ conflictlogtable_nspid =
+ QualifiedNameGetCreationNamespace(names, &conflictlogtable);
+ values[Anum_pg_subscription_subconflictlognspid - 1] =
+ ObjectIdGetDatum(conflictlogtable_nspid);
+ values[Anum_pg_subscription_subconflictlogtable - 1] =
+ CStringGetTextDatum(conflictlogtable);
+ }
+ else
+ nulls[Anum_pg_subscription_subconflictlogtable - 1] = true;
+
tup = heap_form_tuple(RelationGetDescr(rel), values, nulls);
/* Insert tuple into catalog. */
@@ -768,6 +807,10 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
ReplicationOriginNameForLogicalRep(subid, InvalidOid, originname, sizeof(originname));
replorigin_create(originname);
+ /* If a conflict log table name is given then create the table. */
+ if (opts.conflictlogtable)
+ create_conflict_log_table(conflictlogtable_nspid, conflictlogtable);
+
/*
* Connect to remote side to execute requested commands and fetch table
* and sequence info.
@@ -1410,7 +1453,8 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
SUBOPT_MAX_RETENTION_DURATION |
- SUBOPT_ORIGIN);
+ SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_TABLE);
parse_subscription_options(pstate, stmt->options,
supported_opts, &opts);
@@ -1665,6 +1709,59 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
origin = opts.origin;
}
+ if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_LOG_TABLE))
+ {
+ Oid nspid;
+ char *relname = NULL;
+ Oid old_nspid = InvalidOid;
+ char *old_relname = NULL;
+ List *names =
+ stringToQualifiedNameList(opts.conflictlogtable, NULL);
+
+ nspid = QualifiedNameGetCreationNamespace(names, &relname);
+
+ /*
+ * If the subscription already uses this conflict log table
+ * and it exists, just issue a notice.
+ */
+ old_relname =
+ get_subscription_conflict_log_table(subid, &old_nspid);
+ if (old_relname != NULL &&
+ strcmp(old_relname, relname) == 0 &&
+ old_nspid == nspid &&
+ OidIsValid(get_relname_relid(relname, nspid)))
+ {
+ char *nspname = get_namespace_name(nspid);
+
+ ereport(NOTICE,
+ (errmsg("\"%s.%s\" is already in use as the conflict log table for this subscription",
+ nspname, relname)));
+ pfree(nspname);
+ }
+ else
+ {
+ /*
+ * Create the conflict log table after dropping any
+ * pre-existing one.
+ */
+ if (old_relname)
+ drop_conflict_log_table(old_nspid, old_relname);
+ create_conflict_log_table(nspid, relname);
+
+ values[Anum_pg_subscription_subconflictlognspid - 1] =
+ ObjectIdGetDatum(nspid);
+ values[Anum_pg_subscription_subconflictlogtable - 1] =
+ CStringGetTextDatum(relname);
+ replaces[Anum_pg_subscription_subconflictlognspid - 1] =
+ true;
+ replaces[Anum_pg_subscription_subconflictlogtable - 1] =
+ true;
+ }
+
+ if (old_relname != NULL)
+ pfree(old_relname);
+ }
+
update_tuple = true;
break;
}
@@ -2027,6 +2124,8 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
Form_pg_subscription form;
List *rstates;
bool must_use_password;
+ Oid conflictlogtable_nsp = InvalidOid;
+ char *conflictlogtable = NULL;
/*
* The launcher may concurrently start a new worker for this subscription.
@@ -2110,6 +2209,20 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
ObjectAddressSet(myself, SubscriptionRelationId, subid);
EventTriggerSQLDropAddObject(&myself, true, true);
+ /* Fetch the conflict log table information. */
+ conflictlogtable =
+ get_subscription_conflict_log_table(subid, &conflictlogtable_nsp);
+
+ /*
+ * If the subscription had a conflict log table, drop it now. This happens
+ * before deleting the subscription tuple.
+ */
+ if (conflictlogtable)
+ {
+ drop_conflict_log_table(conflictlogtable_nsp, conflictlogtable);
+ pfree(conflictlogtable);
+ }
+
/* Remove the tuple from catalog. */
CatalogTupleDelete(rel, &tup->t_self);
@@ -3188,3 +3301,87 @@ defGetStreamingMode(DefElem *def)
def->defname)));
return LOGICALREP_STREAM_OFF; /* keep compiler quiet */
}
+
+/*
+ * Create conflict log table.
+ *
+ * The subscription owner becomes the owner of this table and has all
+ * privileges on it.
+ */
+static void
+create_conflict_log_table(Oid namespaceId, char *conflictrel)
+{
+ StringInfoData querybuf;
+
+ /* Report an error if the specified conflict log table already exists. */
+ if (OidIsValid(get_relname_relid(conflictrel, namespaceId)))
+ ereport(ERROR,
+ (errcode(ERRCODE_DUPLICATE_TABLE),
+ errmsg("cannot create conflict log table \"%s.%s\" because a table with that name already exists",
+ get_namespace_name(namespaceId), conflictrel),
+ errhint("Use a different name for the conflict log table or drop the existing table.")));
+
+ initStringInfo(&querybuf);
+
+ /* build and execute the CREATE TABLE query. */
+ appendStringInfo(&querybuf,
+ "CREATE TABLE %s.%s ("
+ "relid Oid,"
+ "schemaname TEXT,"
+ "relname TEXT,"
+ "conflict_type TEXT,"
+ "local_xid xid,"
+ "remote_xid xid,"
+ "remote_commit_lsn pg_lsn,"
+ "local_commit_ts TIMESTAMPTZ,"
+ "remote_commit_ts TIMESTAMPTZ,"
+ "local_origin TEXT,"
+ "remote_origin TEXT,"
+ "key_tuple JSON,"
+ "local_tuple JSON,"
+ "remote_tuple JSON)",
+ quote_identifier(get_namespace_name(namespaceId)),
+ quote_identifier(conflictrel));
+
+ if (SPI_connect() != SPI_OK_CONNECT)
+ elog(ERROR, "SPI_connect failed");
+
+ if (SPI_execute(querybuf.data, false, 0) != SPI_OK_UTILITY)
+ elog(ERROR, "SPI_exec failed: %s", querybuf.data);
+
+ if (SPI_finish() != SPI_OK_FINISH)
+ elog(ERROR, "SPI_finish failed");
+
+ pfree(querybuf.data);
+}
+
+/*
+ * Drop the conflict log table.
+ *
+ * This function uses SPI to execute DROP TABLE IF EXISTS.
+ * We use IF EXISTS to avoid errors if the user manually dropped it first.
+ */
+static void
+drop_conflict_log_table(Oid namespaceId, char *conflictrel)
+{
+ StringInfoData querybuf;
+
+ initStringInfo(&querybuf);
+
+ /* Drop the conflict log table if it exists. */
+ appendStringInfo(&querybuf,
+ "DROP TABLE IF EXISTS %s.%s",
+ quote_identifier(get_namespace_name(namespaceId)),
+ quote_identifier(conflictrel));
+
+ if (SPI_connect() != SPI_OK_CONNECT)
+ elog(ERROR, "SPI_connect failed");
+
+ if (SPI_execute(querybuf.data, false, 0) != SPI_OK_UTILITY)
+ elog(ERROR, "SPI_exec failed: %s", querybuf.data);
+
+ if (SPI_finish() != SPI_OK_FINISH)
+ elog(ERROR, "SPI_finish failed");
+
+ pfree(querybuf.data);
+}
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 16695592265..eab528a60a8 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -15,13 +15,25 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/tableam.h"
+#include "access/table.h"
+#include "catalog/indexing.h"
+#include "catalog/namespace.h"
+#include "catalog/pg_namespace_d.h"
+#include "catalog/pg_type.h"
#include "executor/executor.h"
+#include "executor/spi.h"
+#include "funcapi.h"
+#include "funcapi.h"
#include "pgstat.h"
#include "replication/conflict.h"
#include "replication/worker_internal.h"
#include "storage/lmgr.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/lsyscache.h"
+#include "utils/pg_lsn.h"
static const char *const ConflictTypeNames[] = {
[CT_INSERT_EXISTS] = "insert_exists",
@@ -50,8 +62,26 @@ static char *build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *localslot,
TupleTableSlot *remoteslot,
Oid indexoid);
+static void build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull);
static char *build_index_value_desc(EState *estate, Relation localrel,
TupleTableSlot *slot, Oid indexoid);
+static Datum tuple_table_slot_to_json_datum(TupleTableSlot *slot);
+static Datum tuple_table_slot_to_ri_json_datum(EState *estate,
+ Relation localrel,
+ Oid replica_index,
+ TupleTableSlot *slot);
+
+static void insert_conflict_log(EState *estate, Relation rel,
+ TransactionId local_xid,
+ TimestampTz local_ts,
+ ConflictType conflict_type,
+ RepOriginId origin_id,
+ TupleTableSlot *searchslot,
+ TupleTableSlot *localslot,
+ TupleTableSlot *remoteslot);
/*
* Get the xmin and commit timestamp data (origin and timestamp) associated
@@ -112,6 +142,7 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
/* Form errdetail message by combining conflicting tuples information. */
foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
+ {
errdetail_apply_conflict(estate, relinfo, type, searchslot,
conflicttuple->slot, remoteslot,
conflicttuple->indexoid,
@@ -120,6 +151,15 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
conflicttuple->ts,
&err_detail);
+ /* Insert conflict details to conflict log table. */
+ insert_conflict_log(estate, relinfo->ri_RelationDesc,
+ conflicttuple->xmin,
+ conflicttuple->ts, type,
+ conflicttuple->origin,
+ searchslot, conflicttuple->slot,
+ remoteslot);
+ }
+
pgstat_report_subscription_conflict(MySubscription->oid, type);
ereport(elevel,
@@ -472,6 +512,40 @@ build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
return tuple_value.data;
}
+/*
+ * Helper function to extract the "raw" index key Datums and their null flags
+ * from a TupleTableSlot, given an already open index descriptor.
+ * This is the reusable core logic.
+ */
+static void
+build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull)
+{
+ TupleTableSlot *tableslot = slot;
+
+ /*
+ * If the slot is a virtual slot, copy it into a heap tuple slot as
+ * FormIndexDatum only works with heap tuple slots.
+ */
+ if (TTS_IS_VIRTUAL(slot))
+ {
+ /* Slot is created within the EState's tuple table */
+ tableslot = table_slot_create(localrel, &estate->es_tupleTable);
+ tableslot = ExecCopySlot(tableslot, slot);
+ }
+
+ /*
+ * Initialize ecxt_scantuple for potential use in FormIndexDatum
+ */
+ GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+
+ /* Form the index datums */
+ FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values,
+ isnull);
+}
+
/*
* Helper functions to construct a string describing the contents of an index
* entry. See BuildIndexValueDescription for details.
@@ -487,41 +561,215 @@ build_index_value_desc(EState *estate, Relation localrel, TupleTableSlot *slot,
Relation indexDesc;
Datum values[INDEX_MAX_KEYS];
bool isnull[INDEX_MAX_KEYS];
- TupleTableSlot *tableslot = slot;
- if (!tableslot)
+ if (!slot)
return NULL;
Assert(CheckRelationOidLockedByMe(indexoid, RowExclusiveLock, true));
indexDesc = index_open(indexoid, NoLock);
- /*
- * If the slot is a virtual slot, copy it into a heap tuple slot as
- * FormIndexDatum only works with heap tuple slots.
- */
- if (TTS_IS_VIRTUAL(slot))
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+
+ index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+
+ index_close(indexDesc, NoLock);
+
+ return index_value;
+}
+
+/*
+ * tuple_table_slot_to_json_datum
+ *
+ * Helper function to convert a TupleTableSlot to Jsonb.
+ */
+static Datum
+tuple_table_slot_to_json_datum(TupleTableSlot *slot)
+{
+ HeapTuple tuple;
+ Datum datum;
+ Datum json;
+
+ Assert(slot != NULL);
+
+ tuple = ExecCopySlotHeapTuple(slot);
+ datum = heap_copy_tuple_as_datum(tuple, slot->tts_tupleDescriptor);
+
+ json = DirectFunctionCall1(row_to_json, datum);
+ heap_freetuple(tuple);
+
+ return json;
+}
+
+/*
+ * tuple_table_slot_to_ri_json_datum
+ *
+ * Fetch replica identity key from the tuple table slot and convert into a
+ * jsonb datum.
+ */
+static Datum
+tuple_table_slot_to_ri_json_datum(EState *estate, Relation localrel,
+ Oid replica_index, TupleTableSlot *slot)
+{
+ Relation indexDesc;
+ Datum values[INDEX_MAX_KEYS];
+ bool isnull[INDEX_MAX_KEYS];
+ HeapTuple tuple;
+ TupleDesc tupdesc;
+ Datum datum;
+
+ Assert(slot != NULL);
+
+ Assert(CheckRelationOidLockedByMe(replica_index, RowExclusiveLock, true));
+
+ indexDesc = index_open(replica_index, NoLock);
+
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+ tupdesc = RelationGetDescr(indexDesc);
+
+ /* Bless the tupdesc so it can be looked up by row_to_json. */
+ BlessTupleDesc(tupdesc);
+
+ /* Form the replica identity tuple. */
+ tuple = heap_form_tuple(tupdesc, values, isnull);
+ datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ index_close(indexDesc, NoLock);
+
+ /* Convert to a JSONB datum. */
+ return DirectFunctionCall1(row_to_json, datum);
+}
+
+/*
+ * insert_conflict_log
+ *
+ * Insert details about a logical replication conflict to a conflict log table.
+ */
+static void
+insert_conflict_log(EState *estate, Relation rel, TransactionId local_xid,
+ TimestampTz local_ts, ConflictType conflict_type,
+ RepOriginId origin_id, TupleTableSlot *searchslot,
+ TupleTableSlot *localslot, TupleTableSlot *remoteslot)
+{
+#define MAX_CONFLICT_ATTR_NUM 15
+ Datum values[MAX_CONFLICT_ATTR_NUM];
+ bool nulls[MAX_CONFLICT_ATTR_NUM];
+ Oid nspid;
+ Oid confliglogreid;
+ Relation conflictlogrel = NULL;
+ int attno;
+ int options = HEAP_INSERT_NO_LOGICAL;
+ char *conflictlogtable;
+ char *origin = NULL;
+ char *remote_origin = NULL;
+ HeapTuple tup;
+
+ /* If conflict log table is not set for the subscription just return. */
+ conflictlogtable = get_subscription_conflict_log_table(
+ MyLogicalRepWorker->subid, &nspid);
+ if (conflictlogtable == NULL)
+ return;
+
+ confliglogreid = get_relname_relid(conflictlogtable, nspid);
+ if (OidIsValid(confliglogreid))
+ conflictlogrel = table_open(confliglogreid, RowExclusiveLock);
+
+ /* Conflict log table is dropped or not accessible. */
+ if (conflictlogrel == NULL)
+ ereport(ERROR,
+ (errcode(ERRCODE_UNDEFINED_TABLE),
+ errmsg("conflict log table \"%s.%s\" does not exist",
+ get_namespace_name(nspid), conflictlogtable)));
+
+ /* Initialize values and nulls arrays. */
+ memset(values, 0, sizeof(Datum) * MAX_CONFLICT_ATTR_NUM);
+ memset(nulls, 0, sizeof(bool) * MAX_CONFLICT_ATTR_NUM);
+
+ /* Populate the values and nulls arrays. */
+ attno = 0;
+ values[attno++] = ObjectIdGetDatum(RelationGetRelid(rel));
+
+ values[attno++] =
+ CStringGetTextDatum(get_namespace_name(RelationGetNamespace(rel)));
+
+ values[attno++] = CStringGetTextDatum(RelationGetRelationName(rel));
+
+ values[attno++] = CStringGetTextDatum(ConflictTypeNames[conflict_type]);
+
+ if (TransactionIdIsValid(local_xid))
+ values[attno++] = TransactionIdGetDatum(local_xid);
+ else
+ nulls[attno++] = true;
+
+ if (TransactionIdIsValid(remote_xid))
+ values[attno++] = TransactionIdGetDatum(remote_xid);
+ else
+ nulls[attno++] = true;
+
+ values[attno++] = LSNGetDatum(remote_final_lsn);
+
+ if (local_ts > 0)
+ values[attno++] = TimestampTzGetDatum(local_ts);
+ else
+ nulls[attno++] = true;
+
+ if (remote_commit_ts > 0)
+ values[attno++] = TimestampTzGetDatum(remote_commit_ts);
+ else
+ nulls[attno++] = true;
+
+ if (origin_id != InvalidRepOriginId)
+ replorigin_by_oid(origin_id, true, &origin);
+
+ if (origin != NULL)
+ values[attno++] = CStringGetTextDatum(origin);
+ else
+ nulls[attno++] = true;
+
+ if (replorigin_session_origin != InvalidRepOriginId)
+ replorigin_by_oid(replorigin_session_origin, true, &remote_origin);
+
+ if (remote_origin != NULL)
+ values[attno++] = CStringGetTextDatum(remote_origin);
+ else
+ nulls[attno++] = true;
+
+ if (searchslot != NULL && !TupIsNull(searchslot))
{
- tableslot = table_slot_create(localrel, &estate->es_tupleTable);
- tableslot = ExecCopySlot(tableslot, slot);
+ Oid replica_index = GetRelationIdentityOrPK(rel);
+
+ /*
+ * If the table has a valid replica identity index, build the index
+ * json datum from key value. Otherwise, construct it from the complete
+ * tuple in REPLICA IDENTITY FULL cases.
+ */
+ if (OidIsValid(replica_index))
+ values[attno++] = tuple_table_slot_to_ri_json_datum(estate, rel,
+ replica_index,
+ searchslot);
+ else
+ values[attno++] = tuple_table_slot_to_json_datum(searchslot);
}
+ else
+ nulls[attno++] = true;
- /*
- * Initialize ecxt_scantuple for potential use in FormIndexDatum when
- * index expressions are present.
- */
- GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+ if (localslot != NULL && !TupIsNull(localslot))
+ values[attno++] = tuple_table_slot_to_json_datum(localslot);
+ else
+ nulls[attno++] = true;
- /*
- * The values/nulls arrays passed to BuildIndexValueDescription should be
- * the results of FormIndexDatum, which are the "raw" input to the index
- * AM.
- */
- FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values, isnull);
+ if (remoteslot != NULL && !TupIsNull(remoteslot))
+ values[attno++] = tuple_table_slot_to_json_datum(remoteslot);
+ else
+ nulls[attno++] = true;
- index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+ Assert(attno + 1 == MAX_CONFLICT_ATTR_NUM);
- index_close(indexDesc, NoLock);
+ tup = heap_form_tuple(RelationGetDescr(conflictlogrel), values, nulls);
+ heap_insert(conflictlogrel, tup, GetCurrentCommandId(true), options, NULL);
+ table_close(conflictlogrel, RowExclusiveLock);
- return index_value;
+ pfree(conflictlogtable);
}
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 93970c6af29..e6c02685ec9 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -482,7 +482,9 @@ static bool MySubscriptionValid = false;
static List *on_commit_wakeup_workers_subids = NIL;
bool in_remote_transaction = false;
-static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+TransactionId remote_xid = InvalidTransactionId;
+TimestampTz remote_commit_ts = 0;
/* fields valid only when processing streamed transaction */
static bool in_streamed_transaction = false;
@@ -1219,6 +1221,8 @@ apply_handle_begin(StringInfo s)
set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
remote_final_lsn = begin_data.final_lsn;
+ remote_commit_ts = begin_data.committime;
+ remote_xid = begin_data.xid;
maybe_start_skipping_changes(begin_data.final_lsn);
@@ -1745,6 +1749,10 @@ apply_handle_stream_start(StringInfo s)
/* extract XID of the top-level transaction */
stream_xid = logicalrep_read_stream_start(s, &first_segment);
+ remote_xid = stream_xid;
+ remote_final_lsn = InvalidXLogRecPtr;
+ remote_commit_ts = 0;
+
if (!TransactionIdIsValid(stream_xid))
ereport(ERROR,
(errcode(ERRCODE_PROTOCOL_VIOLATION),
diff --git a/src/backend/utils/cache/lsyscache.c b/src/backend/utils/cache/lsyscache.c
index fa7cd7e06a7..12db6676406 100644
--- a/src/backend/utils/cache/lsyscache.c
+++ b/src/backend/utils/cache/lsyscache.c
@@ -3881,3 +3881,41 @@ get_subscription_name(Oid subid, bool missing_ok)
return subname;
}
+
+/*
+ * get_subscription_conflict_log_table
+ *
+ * Get conflict log table name and namespace id from subscription.
+ */
+char *
+get_subscription_conflict_log_table(Oid subid, Oid *nspid)
+{
+ HeapTuple tup;
+ Datum datum;
+ bool isnull;
+ char *relname = NULL;
+ Form_pg_subscription subform;
+
+ *nspid = InvalidOid;
+
+ tup = SearchSysCache1(SUBSCRIPTIONOID, ObjectIdGetDatum(subid));
+
+ if (!HeapTupleIsValid(tup))
+ return NULL;
+
+ subform = (Form_pg_subscription) GETSTRUCT(tup);
+
+ /* Get conflict log table name. */
+ datum = SysCacheGetAttr(SUBSCRIPTIONOID,
+ tup,
+ Anum_pg_subscription_subconflictlogtable,
+ &isnull);
+ if (!isnull)
+ {
+ *nspid = subform->subconflictlognspid;
+ relname = pstrdup(TextDatumGetCString(datum));
+ }
+
+ ReleaseSysCache(tup);
+ return relname;
+}
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index 36f24502842..c18ec248e5d 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -6806,7 +6806,7 @@ describeSubscriptions(const char *pattern, bool verbose)
printQueryOpt myopt = pset.popt;
static const bool translate_columns[] = {false, false, false, false,
false, false, false, false, false, false, false, false, false, false,
- false, false, false, false};
+ false, false, false, false, false};
if (pset.sversion < 100000)
{
@@ -6900,6 +6900,12 @@ describeSubscriptions(const char *pattern, bool verbose)
appendPQExpBuffer(&buf,
", subskiplsn AS \"%s\"\n",
gettext_noop("Skip LSN"));
+
+ /* Conflict log table is only supported in v19 and higher */
+ if (pset.sversion >= 190000)
+ appendPQExpBuffer(&buf,
+ ", subconflictlogtable AS \"%s\"\n",
+ gettext_noop("Conflict log table"));
}
/* Only display subscriptions in current database. */
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 51806597037..4057c0a22b4 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -2344,8 +2344,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("(", "PUBLICATION");
/* ALTER SUBSCRIPTION <name> SET ( */
else if (Matches("ALTER", "SUBSCRIPTION", MatchAny, MatchAnyN, "SET", "("))
- COMPLETE_WITH("binary", "disable_on_error", "failover",
- "max_retention_duration", "origin",
+ COMPLETE_WITH("binary", "conflict_log_table", "disable_on_error",
+ "failover", "max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
"synchronous_commit", "two_phase");
@@ -3814,8 +3814,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("WITH (");
/* Complete "CREATE SUBSCRIPTION <name> ... WITH ( <opt>" */
else if (Matches("CREATE", "SUBSCRIPTION", MatchAnyN, "WITH", "("))
- COMPLETE_WITH("binary", "connect", "copy_data", "create_slot",
- "disable_on_error", "enabled", "failover",
+ COMPLETE_WITH("binary", "conflict_log_table", "connect", "copy_data",
+ "create_slot", "disable_on_error", "enabled", "failover",
"max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index 55cb9b1eefa..f4526c15ec3 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -80,6 +80,8 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
bool subretaindeadtuples; /* True if dead tuples useful for
* conflict detection are retained */
+ Oid subconflictlognspid; /* Namespace Oid in which the conflict
+ * log table is created. */
int32 submaxretention; /* The maximum duration (in milliseconds)
* for which information useful for
@@ -105,6 +107,9 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
/* Only publish data originating from the specified origin */
text suborigin BKI_DEFAULT(LOGICALREP_ORIGIN_ANY);
+
+ /* Conflict log table name if specified */
+ text subconflictlogtable;
#endif
} FormData_pg_subscription;
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index f081619f151..314ac5dc746 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -256,6 +256,10 @@ extern PGDLLIMPORT bool InitializingApplyWorker;
extern PGDLLIMPORT List *table_states_not_ready;
+extern XLogRecPtr remote_final_lsn;
+extern TimestampTz remote_commit_ts;
+extern TransactionId remote_xid;
+
extern void logicalrep_worker_attach(int slot);
extern LogicalRepWorker *logicalrep_worker_find(LogicalRepWorkerType wtype,
Oid subid, Oid relid,
diff --git a/src/include/utils/lsyscache.h b/src/include/utils/lsyscache.h
index 50fb149e9ac..3bebf04bf51 100644
--- a/src/include/utils/lsyscache.h
+++ b/src/include/utils/lsyscache.h
@@ -210,6 +210,7 @@ extern Oid get_publication_oid(const char *pubname, bool missing_ok);
extern char *get_publication_name(Oid pubid, bool missing_ok);
extern Oid get_subscription_oid(const char *subname, bool missing_ok);
extern char *get_subscription_name(Oid subid, bool missing_ok);
+extern char *get_subscription_conflict_log_table(Oid subid, Oid *nspid);
#define type_is_array(typid) (get_element_type(typid) != InvalidOid)
/* type_is_array_domain accepts both plain arrays and domains over arrays */
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index 327d1e7731f..6a89784e1da 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -116,18 +116,18 @@ CREATE SUBSCRIPTION regress_testsub4 CONNECTION 'dbname=regress_doesnotexist' PU
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub4 SET (origin = any);
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
DROP SUBSCRIPTION regress_testsub3;
@@ -145,10 +145,10 @@ ALTER SUBSCRIPTION regress_testsub CONNECTION 'foobar';
ERROR: invalid connection string syntax: missing "=" after "foobar" in connection info string
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET PUBLICATION testpub2, testpub3 WITH (refresh = false);
@@ -157,10 +157,10 @@ ALTER SUBSCRIPTION regress_testsub SET (slot_name = 'newname');
ALTER SUBSCRIPTION regress_testsub SET (password_required = false);
ALTER SUBSCRIPTION regress_testsub SET (run_as_owner = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (password_required = true);
@@ -176,10 +176,10 @@ ERROR: unrecognized subscription parameter: "create_slot"
-- ok
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/12345');
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345 |
(1 row)
-- ok - with lsn = NONE
@@ -188,10 +188,10 @@ ALTER SUBSCRIPTION regress_testsub SKIP (lsn = NONE);
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/0');
ERROR: invalid WAL location (LSN): 0/0
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 |
(1 row)
BEGIN;
@@ -223,10 +223,10 @@ ALTER SUBSCRIPTION regress_testsub_foo SET (synchronous_commit = foobar);
ERROR: invalid value for parameter "synchronous_commit": "foobar"
HINT: Available values: local, remote_write, remote_apply, on, off.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
----------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+---------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000 |
(1 row)
-- rename back to keep the rest simple
@@ -255,19 +255,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (binary = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -279,27 +279,27 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = parallel);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- fail - publication already exists
@@ -314,10 +314,10 @@ ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refr
ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refresh = false);
ERROR: publication "testpub1" is already in subscription "regress_testsub"
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- fail - publication used more than once
@@ -332,10 +332,10 @@ ERROR: publication "testpub3" is not in subscription "regress_testsub"
-- ok - delete publications
ALTER SUBSCRIPTION regress_testsub DROP PUBLICATION testpub1, testpub2 WITH (refresh = false);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -371,19 +371,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- we can alter streaming when two_phase enabled
ALTER SUBSCRIPTION regress_testsub SET (streaming = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -393,10 +393,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -409,18 +409,18 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (disable_on_error = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -433,10 +433,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -450,19 +450,19 @@ NOTICE: max_retention_duration is ineffective when retain_dead_tuples is disabl
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- ok
ALTER SUBSCRIPTION regress_testsub SET (max_retention_duration = 0);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -517,6 +517,129 @@ COMMIT;
-- ok, owning it is enough for this stuff
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG TABLE TESTS
+--
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+-- fail - conflict_log_table specified when table already exists
+CREATE TABLE public.regress_conflict_log_temp (id int);
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log_temp');
+ERROR: cannot create conflict log table "public.regress_conflict_log_temp" because a table with that name already exists
+HINT: Use a different name for the conflict log table or drop the existing table.
+DROP TABLE public.regress_conflict_log_temp;
+-- ok - conflict_log_table creation with CREATE SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- check metadata in pg_subscription
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname | subconflictlogtable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test1 | regress_conflict_log1 | t
+(1 row)
+
+-- check if the table exists and has the correct schema (15 columns)
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attnum > 0;
+ count
+-------
+ 14
+(1 row)
+
+-- check a specific column type (e.g., key_tuple should be JSON)
+SELECT format_type(atttypid, atttypmod) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attname = 'key_tuple';
+ format_type
+-------------
+ json
+(1 row)
+
+\dRs+
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+-----------------------
+ regress_conflict_test1 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | regress_conflict_log1
+(1 row)
+
+-- ok - adding conflict_log_table with ALTER SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log2');
+-- check metadata after ALTER
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogtable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test2 | regress_conflict_log2 | t
+(1 row)
+
+-- ok - change the conflict log table name for an existing subscription that already had one
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log3');
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogtable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test2 | regress_conflict_log3 | t
+(1 row)
+
+\dRs+
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+-----------------------
+ regress_conflict_test1 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | regress_conflict_log1
+ regress_conflict_test2 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | regress_conflict_log3
+(2 rows)
+
+-- check the new table was created and the old table was dropped
+SELECT count(*) FROM pg_class WHERE relname = 'regress_conflict_log2';
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log3'::regclass AND attnum > 0;
+ count
+-------
+ 14
+(1 row)
+
+-- ok (NOTICE) - set conflict_log_table to one already used by this subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log3');
+NOTICE: "public.regress_conflict_log3" is already in use as the conflict log table for this subscription
+-- fail - set conflict_log_table to one already used by a different subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log1');
+ERROR: cannot create conflict log table "public.regress_conflict_log1" because a table with that name already exists
+HINT: Use a different name for the conflict log table or drop the existing table.
+-- ok - dropping subscription also drops the log table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the table was dropped
+SELECT to_regclass('public.regress_conflict_log1');
+ to_regclass
+-------------
+
+(1 row)
+
+-- ok - dropping subscription when the log table was manually dropped first
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+DROP TABLE public.regress_conflict_log1;
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+NOTICE: table "regress_conflict_log1" does not exist, skipping
+-- should return NULL, meaning the subscription was dropped successfully
+SELECT subname FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname
+---------
+(0 rows)
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
RESET SESSION AUTHORIZATION;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index ef0c298d2df..a78ccbc3c01 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -365,6 +365,79 @@ COMMIT;
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG TABLE TESTS
+--
+
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+
+-- fail - conflict_log_table specified when table already exists
+CREATE TABLE public.regress_conflict_log_temp (id int);
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log_temp');
+DROP TABLE public.regress_conflict_log_temp;
+
+-- ok - conflict_log_table creation with CREATE SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+
+-- check metadata in pg_subscription
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- check if the table exists and has the correct schema (15 columns)
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attnum > 0;
+
+-- check a specific column type (e.g., key_tuple should be JSON)
+SELECT format_type(atttypid, atttypmod) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attname = 'key_tuple';
+
+\dRs+
+
+-- ok - adding conflict_log_table with ALTER SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log2');
+
+-- check metadata after ALTER
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- ok - change the conflict log table name for an existing subscription that already had one
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log3');
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+\dRs+
+
+-- check the new table was created and the old table was dropped
+SELECT count(*) FROM pg_class WHERE relname = 'regress_conflict_log2';
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log3'::regclass AND attnum > 0;
+
+-- ok (NOTICE) - set conflict_log_table to one already used by this subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log3');
+
+-- fail - set conflict_log_table to one already used by a different subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log1');
+
+-- ok - dropping subscription also drops the log table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the table was dropped
+SELECT to_regclass('public.regress_conflict_log1');
+
+-- ok - dropping subscription when the log table was manually dropped first
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+DROP TABLE public.regress_conflict_log1;
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the subscription was dropped successfully
+SELECT subname FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+
RESET SESSION AUTHORIZATION;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
--
2.49.0
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-11-25 03:33 Peter Smith <smithpb2250@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Peter Smith @ 2025-11-25 03:33 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Hi Dilip.
Here are a couple of review comments for v6-0001.
======
GENERAL.
1.
Firstly, here is one of my "what if" ideas...
The current patch is described as making a "structured, queryable
record of all logical replication conflicts".
What if we go bigger than that? What if this were made a more generic
"structured, queryable record of logical replication activity"?
AFAIK, there don't have to be too many logic changes to achieve this.
e.g. I'm imagining it is mostly:
* Rename the subscription parameter "conflict_log_table" to
"log_table" or similar.
* Remove/modify the "conflict_" name part from many of the variables
and function names.
* Add another 'type' column to the log table -- e.g. everything this
patch writes can be type="CONFL", or type='c', or whatever.
* Maybe tweak/add some of the other columns for more generic future use
Anyway, it might be worth considering this now, before everything
becomes set in stone with a conflict-only focus, making it too
difficult to add more potential/unknown log table enhancements later.
Thoughts?
======
src/backend/replication/logical/conflict.c
2.
+#include "funcapi.h"
+#include "funcapi.h"
double include of the same header.
~~~
3.
+static Datum tuple_table_slot_to_ri_json_datum(EState *estate,
+ Relation localrel,
+ Oid replica_index,
+ TupleTableSlot *slot);
+
+static void insert_conflict_log(EState *estate, Relation rel,
+ TransactionId local_xid,
+ TimestampTz local_ts,
+ ConflictType conflict_type,
+ RepOriginId origin_id,
+ TupleTableSlot *searchslot,
+ TupleTableSlot *localslot,
+ TupleTableSlot *remoteslot);
There were no spaces between any of the other static declarations, so
why is this one different?
~~~
insert_conflict_log:
insert_conflict_log:
4.
+#define MAX_CONFLICT_ATTR_NUM 15
+ Datum values[MAX_CONFLICT_ATTR_NUM];
+ bool nulls[MAX_CONFLICT_ATTR_NUM];
+ Oid nspid;
+ Oid confliglogreid;
+ Relation conflictlogrel = NULL;
+ int attno;
+ int options = HEAP_INSERT_NO_LOGICAL;
+ char *conflictlogtable;
+ char *origin = NULL;
+ char *remote_origin = NULL;
+ HeapTuple tup;
Typo: Oops. Looks like that typo originated from my previous review
comment, and you took it as-is.
/confliglogreid/confliglogrelid/
~~~
5.
+ if (searchslot != NULL && !TupIsNull(searchslot))
{
- tableslot = table_slot_create(localrel, &estate->es_tupleTable);
- tableslot = ExecCopySlot(tableslot, slot);
+ Oid replica_index = GetRelationIdentityOrPK(rel);
+
+ /*
+ * If the table has a valid replica identity index, build the index
+ * json datum from key value. Otherwise, construct it from the complete
+ * tuple in REPLICA IDENTITY FULL cases.
+ */
+ if (OidIsValid(replica_index))
+ values[attno++] = tuple_table_slot_to_ri_json_datum(estate, rel,
+ replica_index,
+ searchslot);
+ else
+ values[attno++] = tuple_table_slot_to_json_datum(searchslot);
}
+ else
+ nulls[attno++] = true;
- /*
- * Initialize ecxt_scantuple for potential use in FormIndexDatum when
- * index expressions are present.
- */
- GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+ if (localslot != NULL && !TupIsNull(localslot))
+ values[attno++] = tuple_table_slot_to_json_datum(localslot);
+ else
+ nulls[attno++] = true;
- /*
- * The values/nulls arrays passed to BuildIndexValueDescription should be
- * the results of FormIndexDatum, which are the "raw" input to the index
- * AM.
- */
- FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values, isnull);
+ if (remoteslot != NULL && !TupIsNull(remoteslot))
+ values[attno++] = tuple_table_slot_to_json_datum(remoteslot);
+ else
+ nulls[attno++] = true;
AFAIK, the TupIsNull() already includes the NULL check anyway, so you
don't need to double up those. I saw at least 3 conditions above where
the code could be simpler. e.g.
BEFORE
+ if (remoteslot != NULL && !TupIsNull(remoteslot))
SUGGESTION
if (!TupIsNull(remoteslot))
======
Kind Regards,
Peter Smith.
Fujitsu Australia
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-11-25 08:29 Dilip Kumar <dilipbalaut@gmail.com>
parent: Peter Smith <smithpb2250@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-11-25 08:29 UTC (permalink / raw)
To: Peter Smith <smithpb2250@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, Nov 25, 2025 at 9:03 AM Peter Smith <smithpb2250@gmail.com> wrote:
>
> Hi Dilip.
>
> Here are a couple of review comments for v6-0001.
>
> ======
> GENERAL.
>
> 1.
> Firstly, here is one of my "what if" ideas...
>
> The current patch is described as making a "structured, queryable
> record of all logical replication conflicts".
>
> What if we go bigger than that? What if this were made a more generic
> "structured, queryable record of logical replication activity"?
>
> AFAIK, there don't have to be too many logic changes to achieve this.
> e.g. I'm imagining it is mostly:
>
> * Rename the subscription parameter "conflict_log_table" to
> "log_table" or similar.
> * Remove/modify the "conflict_" name part from many of the variables
> and function names.
> * Add another 'type' column to the log table -- e.g. everything this
> patch writes can be type="CONFL", or type='c', or whatever.
> * Maybe tweak/add some of the other columns for more generic future use
>
> Anyway, it might be worth considering this now, before everything
> becomes set in stone with a conflict-only focus, making it too
> difficult to add more potential/unknown log table enhancements later.
>
> Thoughts?
Yeah that's an interesting thought for sure, but honestly I believe
the conflict log table only for storing the conflict and conflict
resolution related data is standard followed across the databases who
provide active-active setup e.g. Oracle Golden Gate, BDR, pg active,
so IMHO to keep the feature clean and focused, we should follow the
same.
I will work on other review comments and post the patch soon.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-11-25 10:36 Dilip Kumar <dilipbalaut@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 2 replies; 912+ messages in thread
From: Dilip Kumar @ 2025-11-25 10:36 UTC (permalink / raw)
To: Peter Smith <smithpb2250@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, Nov 25, 2025 at 1:59 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
On a separate note, I've been considering how to manage conflict log
insertions when an error causes the outer transaction to abort, which
seems to be a non-trivial.
Here is what I have in mind:
======================
First, prepare_conflict_log() would be executed from
ReportApplyConflict(). This function would handle all preliminary
work, such as preparing the tuple for the conflict log table. Second,
insert_conflict_log() would be executed. If the error level in
ReportApplyConflict() is LOG, the insertion would occur directly.
Otherwise, the log information would be stored in a global variable
and inserted in a separate transaction once we exit start_apply() due
to the error.
@shveta malik @Amit Kapila let me know what you think? Or do you
think it can be simplified?
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-11-25 14:34 Dilip Kumar <dilipbalaut@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
1 sibling, 0 replies; 912+ messages in thread
From: Dilip Kumar @ 2025-11-25 14:34 UTC (permalink / raw)
To: Peter Smith <smithpb2250@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, Nov 25, 2025 at 4:06 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Tue, Nov 25, 2025 at 1:59 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
>
> On a separate note, I've been considering how to manage conflict log
> insertions when an error causes the outer transaction to abort, which
> seems to be a non-trivial.
>
> Here is what I have in mind:
> ======================
> First, prepare_conflict_log() would be executed from
> ReportApplyConflict(). This function would handle all preliminary
> work, such as preparing the tuple for the conflict log table. Second,
> insert_conflict_log() would be executed. If the error level in
> ReportApplyConflict() is LOG, the insertion would occur directly.
> Otherwise, the log information would be stored in a global variable
> and inserted in a separate transaction once we exit start_apply() due
> to the error.
>
> @shveta malik @Amit Kapila let me know what you think? Or do you
> think it can be simplified?
While digging more into this I am wondering why
CT_MULTIPLE_UNIQUE_CONFLICTS is reported as an error and all other
conflicts as LOG?
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-11-26 08:35 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: shveta malik @ 2025-11-26 08:35 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Peter Smith <smithpb2250@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Tue, Nov 25, 2025 at 4:06 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Tue, Nov 25, 2025 at 1:59 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
>
> On a separate note, I've been considering how to manage conflict log
> insertions when an error causes the outer transaction to abort, which
> seems to be a non-trivial.
>
> Here is what I have in mind:
> ======================
> First, prepare_conflict_log() would be executed from
> ReportApplyConflict(). This function would handle all preliminary
> work, such as preparing the tuple for the conflict log table. Second,
> insert_conflict_log() would be executed. If the error level in
> ReportApplyConflict() is LOG, the insertion would occur directly.
> Otherwise, the log information would be stored in a global variable
> and inserted in a separate transaction once we exit start_apply() due
> to the error.
>
> @shveta malik @Amit Kapila let me know what you think? Or do you
> think it can be simplified?
>
I could not think of a better way. This idea works for me. I had
doubts if it will be okay to start a new transaction in catch-block
(if we plan to do it in start_apply's), but then I found few other
functions doing it (see do_autovacuum, perform_work_item,
_SPI_commit). So IMO, we should be good.
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-11-26 10:45 shveta malik <shveta.malik@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: shveta malik @ 2025-11-26 10:45 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Peter Smith <smithpb2250@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Wed, Nov 26, 2025 at 2:05 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> On Tue, Nov 25, 2025 at 4:06 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Tue, Nov 25, 2025 at 1:59 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> >
> > On a separate note, I've been considering how to manage conflict log
> > insertions when an error causes the outer transaction to abort, which
> > seems to be a non-trivial.
> >
> > Here is what I have in mind:
> > ======================
> > First, prepare_conflict_log() would be executed from
> > ReportApplyConflict(). This function would handle all preliminary
> > work, such as preparing the tuple for the conflict log table. Second,
> > insert_conflict_log() would be executed. If the error level in
> > ReportApplyConflict() is LOG, the insertion would occur directly.
> > Otherwise, the log information would be stored in a global variable
> > and inserted in a separate transaction once we exit start_apply() due
> > to the error.
> >
> > @shveta malik @Amit Kapila let me know what you think? Or do you
> > think it can be simplified?
> >
>
> I could not think of a better way. This idea works for me. I had
> doubts if it will be okay to start a new transaction in catch-block
> (if we plan to do it in start_apply's), but then I found few other
> functions doing it (see do_autovacuum, perform_work_item,
> _SPI_commit). So IMO, we should be good.
>
On re-reading, I think you were not suggesting to handle it in the
CATCH block. Where exactly once we exit start_apply?
But since the situation will arise only in case of ERROR, I thought
handling in catch-block could be one option.
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-11-26 11:18 Dilip Kumar <dilipbalaut@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-11-26 11:18 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Peter Smith <smithpb2250@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Wed, Nov 26, 2025 at 4:15 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> On Wed, Nov 26, 2025 at 2:05 PM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > On Tue, Nov 25, 2025 at 4:06 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > On Tue, Nov 25, 2025 at 1:59 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > >
> > >
> > > On a separate note, I've been considering how to manage conflict log
> > > insertions when an error causes the outer transaction to abort, which
> > > seems to be a non-trivial.
> > >
> > > Here is what I have in mind:
> > > ======================
> > > First, prepare_conflict_log() would be executed from
> > > ReportApplyConflict(). This function would handle all preliminary
> > > work, such as preparing the tuple for the conflict log table. Second,
> > > insert_conflict_log() would be executed. If the error level in
> > > ReportApplyConflict() is LOG, the insertion would occur directly.
> > > Otherwise, the log information would be stored in a global variable
> > > and inserted in a separate transaction once we exit start_apply() due
> > > to the error.
> > >
> > > @shveta malik @Amit Kapila let me know what you think? Or do you
> > > think it can be simplified?
> > >
> >
> > I could not think of a better way. This idea works for me. I had
> > doubts if it will be okay to start a new transaction in catch-block
> > (if we plan to do it in start_apply's), but then I found few other
> > functions doing it (see do_autovacuum, perform_work_item,
> > _SPI_commit). So IMO, we should be good.
> >
>
> On re-reading, I think you were not suggesting to handle it in the
> CATCH block. Where exactly once we exit start_apply?
> But since the situation will arise only in case of ERROR, I thought
> handling in catch-block could be one option.
Yeah it makes sense to handle in catch block, I have done that in the
attached patch and also handled other comments by Peter.
Now pending work status
1) fixed review comments of 0002 and 0003 - Pending
2) Need to add replica identity tuple instead of full tuple -- Done
3) Keeping the logs in case of outer transaction failure by moving log
insertion outside the main transaction - reported by Shveta - Done
(might need more validation and testing)
4) Run pgindent -- planning to do it after we complete the first level
of review - Pending
5) Subscription test cases for logging the actual conflicts - Pending
--
Regards,
Dilip Kumar
Google
Attachments:
[application/octet-stream] v7-0001-Add-configurable-conflict-log-table-for-Logical-R.patch (103.2K, ../../CAFiTN-s97fgCFwnQQgEMafw=-tB2J9JFyt89mUqWkCqrVzOcxA@mail.gmail.com/2-v7-0001-Add-configurable-conflict-log-table-for-Logical-R.patch)
download | inline diff:
From 7349c47aa08d1cddeae63ae4dc710eeae9ba3baa Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 12 Nov 2025 10:43:19 +0530
Subject: [PATCH v7] Add configurable conflict log table for Logical
Replication
This patch adds a feature to provide a structured, queryable record of all
logical replication conflicts. The current approach of logging conflicts as
plain text in the server logs makes it difficult to query, analyze, and
use for external monitoring and automation.
This patch addresses these limitations by introducing a configurable
conflict_log_table option in the CREATE SUBSCRIPTION command. Key design
decisions include:
User-Managed Table: The conflict log is stored in a user-managed table
rather than a system catalog.
Structured Data: Conflict details, including the original and remote tuples,
are stored in JSON columns, providing a flexible format to accommodate different
table schemas.
Comprehensive Information: The log table captures essential attributes such as
local and remote transaction IDs, LSNs, commit timestamps, and conflict type,
providing a complete record for post-mortem analysis.
This feature will make logical replication conflicts easier to monitor and manage,
significantly improving the overall resilience and operability of replication setups.
---
src/backend/commands/subscriptioncmds.c | 203 +++++++++++-
src/backend/replication/logical/conflict.c | 355 +++++++++++++++++++--
src/backend/replication/logical/worker.c | 32 +-
src/backend/utils/cache/lsyscache.c | 38 +++
src/bin/psql/describe.c | 8 +-
src/bin/psql/tab-complete.in.c | 8 +-
src/include/catalog/pg_subscription.h | 5 +
src/include/replication/conflict.h | 4 +
src/include/replication/worker_internal.h | 7 +
src/include/utils/lsyscache.h | 1 +
src/test/regress/expected/subscription.out | 299 ++++++++++++-----
src/test/regress/sql/subscription.sql | 73 +++++
12 files changed, 913 insertions(+), 120 deletions(-)
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index 24b70234b35..fa7d96b95cb 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -34,6 +34,7 @@
#include "commands/event_trigger.h"
#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "executor/spi.h"
#include "miscadmin.h"
#include "nodes/makefuncs.h"
#include "pgstat.h"
@@ -47,10 +48,12 @@
#include "storage/lmgr.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/guc.h"
#include "utils/lsyscache.h"
#include "utils/memutils.h"
#include "utils/pg_lsn.h"
+#include "utils/regproc.h"
#include "utils/syscache.h"
/*
@@ -75,6 +78,7 @@
#define SUBOPT_MAX_RETENTION_DURATION 0x00008000
#define SUBOPT_LSN 0x00010000
#define SUBOPT_ORIGIN 0x00020000
+#define SUBOPT_CONFLICT_LOG_TABLE 0x00040000
/* check if the 'val' has 'bits' set */
#define IsSet(val, bits) (((val) & (bits)) == (bits))
@@ -103,6 +107,7 @@ typedef struct SubOpts
bool retaindeadtuples;
int32 maxretention;
char *origin;
+ char *conflictlogtable;
XLogRecPtr lsn;
} SubOpts;
@@ -135,7 +140,8 @@ static List *merge_publications(List *oldpublist, List *newpublist, bool addpub,
static void ReportSlotConnectionError(List *rstates, Oid subid, char *slotname, char *err);
static void CheckAlterSubOption(Subscription *sub, const char *option,
bool slot_needs_update, bool isTopLevel);
-
+static void create_conflict_log_table(Oid namespaceId, char *conflictrel);
+static void drop_conflict_log_table(Oid namespaceId, char *conflictrel);
/*
* Common option parsing function for CREATE and ALTER SUBSCRIPTION commands.
@@ -191,6 +197,8 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->maxretention = 0;
if (IsSet(supported_opts, SUBOPT_ORIGIN))
opts->origin = pstrdup(LOGICALREP_ORIGIN_ANY);
+ if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_TABLE))
+ opts->conflictlogtable = NULL;
/* Parse options */
foreach(lc, stmt_options)
@@ -402,6 +410,15 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->specified_opts |= SUBOPT_LSN;
opts->lsn = lsn;
}
+ else if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_TABLE) &&
+ strcmp(defel->defname, "conflict_log_table") == 0)
+ {
+ if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_LOG_TABLE))
+ errorConflictingDefElem(defel, pstate);
+
+ opts->specified_opts |= SUBOPT_CONFLICT_LOG_TABLE;
+ opts->conflictlogtable = defGetString(defel);
+ }
else
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
@@ -599,6 +616,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bits32 supported_opts;
SubOpts opts = {0};
AclResult aclresult;
+ Oid conflictlogtable_nspid;
+ char *conflictlogtable;
/*
* Parse and check options.
@@ -612,7 +631,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
SUBOPT_DISABLE_ON_ERR | SUBOPT_PASSWORD_REQUIRED |
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
- SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN);
+ SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_TABLE);
parse_subscription_options(pstate, stmt->options, supported_opts, &opts);
/*
@@ -747,6 +767,25 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
values[Anum_pg_subscription_suborigin - 1] =
CStringGetTextDatum(opts.origin);
+ /*
+ * If a conflict log table name is specified, parse the schema and table
+ * name from the string. Store the namespace OID and the table name in
+ * the pg_subscription catalog tuple.
+ */
+ if (opts.conflictlogtable)
+ {
+ List *names = stringToQualifiedNameList(opts.conflictlogtable, NULL);
+
+ conflictlogtable_nspid =
+ QualifiedNameGetCreationNamespace(names, &conflictlogtable);
+ values[Anum_pg_subscription_subconflictlognspid - 1] =
+ ObjectIdGetDatum(conflictlogtable_nspid);
+ values[Anum_pg_subscription_subconflictlogtable - 1] =
+ CStringGetTextDatum(conflictlogtable);
+ }
+ else
+ nulls[Anum_pg_subscription_subconflictlogtable - 1] = true;
+
tup = heap_form_tuple(RelationGetDescr(rel), values, nulls);
/* Insert tuple into catalog. */
@@ -768,6 +807,10 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
ReplicationOriginNameForLogicalRep(subid, InvalidOid, originname, sizeof(originname));
replorigin_create(originname);
+ /* If a conflict log table name is given then create the table. */
+ if (opts.conflictlogtable)
+ create_conflict_log_table(conflictlogtable_nspid, conflictlogtable);
+
/*
* Connect to remote side to execute requested commands and fetch table
* and sequence info.
@@ -1410,7 +1453,8 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
SUBOPT_MAX_RETENTION_DURATION |
- SUBOPT_ORIGIN);
+ SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_TABLE);
parse_subscription_options(pstate, stmt->options,
supported_opts, &opts);
@@ -1665,6 +1709,59 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
origin = opts.origin;
}
+ if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_LOG_TABLE))
+ {
+ Oid nspid;
+ char *relname = NULL;
+ Oid old_nspid = InvalidOid;
+ char *old_relname = NULL;
+ List *names =
+ stringToQualifiedNameList(opts.conflictlogtable, NULL);
+
+ nspid = QualifiedNameGetCreationNamespace(names, &relname);
+
+ /*
+ * If the subscription already uses this conflict log table
+ * and it exists, just issue a notice.
+ */
+ old_relname =
+ get_subscription_conflict_log_table(subid, &old_nspid);
+ if (old_relname != NULL &&
+ strcmp(old_relname, relname) == 0 &&
+ old_nspid == nspid &&
+ OidIsValid(get_relname_relid(relname, nspid)))
+ {
+ char *nspname = get_namespace_name(nspid);
+
+ ereport(NOTICE,
+ (errmsg("\"%s.%s\" is already in use as the conflict log table for this subscription",
+ nspname, relname)));
+ pfree(nspname);
+ }
+ else
+ {
+ /*
+ * Create the conflict log table after dropping any
+ * pre-existing one.
+ */
+ if (old_relname)
+ drop_conflict_log_table(old_nspid, old_relname);
+ create_conflict_log_table(nspid, relname);
+
+ values[Anum_pg_subscription_subconflictlognspid - 1] =
+ ObjectIdGetDatum(nspid);
+ values[Anum_pg_subscription_subconflictlogtable - 1] =
+ CStringGetTextDatum(relname);
+ replaces[Anum_pg_subscription_subconflictlognspid - 1] =
+ true;
+ replaces[Anum_pg_subscription_subconflictlogtable - 1] =
+ true;
+ }
+
+ if (old_relname != NULL)
+ pfree(old_relname);
+ }
+
update_tuple = true;
break;
}
@@ -2027,6 +2124,8 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
Form_pg_subscription form;
List *rstates;
bool must_use_password;
+ Oid conflictlogtable_nsp = InvalidOid;
+ char *conflictlogtable = NULL;
/*
* The launcher may concurrently start a new worker for this subscription.
@@ -2110,6 +2209,20 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
ObjectAddressSet(myself, SubscriptionRelationId, subid);
EventTriggerSQLDropAddObject(&myself, true, true);
+ /* Fetch the conflict log table information. */
+ conflictlogtable =
+ get_subscription_conflict_log_table(subid, &conflictlogtable_nsp);
+
+ /*
+ * If the subscription had a conflict log table, drop it now. This happens
+ * before deleting the subscription tuple.
+ */
+ if (conflictlogtable)
+ {
+ drop_conflict_log_table(conflictlogtable_nsp, conflictlogtable);
+ pfree(conflictlogtable);
+ }
+
/* Remove the tuple from catalog. */
CatalogTupleDelete(rel, &tup->t_self);
@@ -3188,3 +3301,87 @@ defGetStreamingMode(DefElem *def)
def->defname)));
return LOGICALREP_STREAM_OFF; /* keep compiler quiet */
}
+
+/*
+ * Create conflict log table.
+ *
+ * The subscription owner becomes the owner of this table and has all
+ * privileges on it.
+ */
+static void
+create_conflict_log_table(Oid namespaceId, char *conflictrel)
+{
+ StringInfoData querybuf;
+
+ /* Report an error if the specified conflict log table already exists. */
+ if (OidIsValid(get_relname_relid(conflictrel, namespaceId)))
+ ereport(ERROR,
+ (errcode(ERRCODE_DUPLICATE_TABLE),
+ errmsg("cannot create conflict log table \"%s.%s\" because a table with that name already exists",
+ get_namespace_name(namespaceId), conflictrel),
+ errhint("Use a different name for the conflict log table or drop the existing table.")));
+
+ initStringInfo(&querybuf);
+
+ /* build and execute the CREATE TABLE query. */
+ appendStringInfo(&querybuf,
+ "CREATE TABLE %s.%s ("
+ "relid Oid,"
+ "schemaname TEXT,"
+ "relname TEXT,"
+ "conflict_type TEXT,"
+ "local_xid xid,"
+ "remote_xid xid,"
+ "remote_commit_lsn pg_lsn,"
+ "local_commit_ts TIMESTAMPTZ,"
+ "remote_commit_ts TIMESTAMPTZ,"
+ "local_origin TEXT,"
+ "remote_origin TEXT,"
+ "key_tuple JSON,"
+ "local_tuple JSON,"
+ "remote_tuple JSON)",
+ quote_identifier(get_namespace_name(namespaceId)),
+ quote_identifier(conflictrel));
+
+ if (SPI_connect() != SPI_OK_CONNECT)
+ elog(ERROR, "SPI_connect failed");
+
+ if (SPI_execute(querybuf.data, false, 0) != SPI_OK_UTILITY)
+ elog(ERROR, "SPI_exec failed: %s", querybuf.data);
+
+ if (SPI_finish() != SPI_OK_FINISH)
+ elog(ERROR, "SPI_finish failed");
+
+ pfree(querybuf.data);
+}
+
+/*
+ * Drop the conflict log table.
+ *
+ * This function uses SPI to execute DROP TABLE IF EXISTS.
+ * We use IF EXISTS to avoid errors if the user manually dropped it first.
+ */
+static void
+drop_conflict_log_table(Oid namespaceId, char *conflictrel)
+{
+ StringInfoData querybuf;
+
+ initStringInfo(&querybuf);
+
+ /* Drop the conflict log table if it exists. */
+ appendStringInfo(&querybuf,
+ "DROP TABLE IF EXISTS %s.%s",
+ quote_identifier(get_namespace_name(namespaceId)),
+ quote_identifier(conflictrel));
+
+ if (SPI_connect() != SPI_OK_CONNECT)
+ elog(ERROR, "SPI_connect failed");
+
+ if (SPI_execute(querybuf.data, false, 0) != SPI_OK_UTILITY)
+ elog(ERROR, "SPI_exec failed: %s", querybuf.data);
+
+ if (SPI_finish() != SPI_OK_FINISH)
+ elog(ERROR, "SPI_finish failed");
+
+ pfree(querybuf.data);
+}
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 16695592265..e54370a9d6d 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -15,13 +15,24 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/tableam.h"
+#include "access/table.h"
+#include "catalog/indexing.h"
+#include "catalog/namespace.h"
+#include "catalog/pg_namespace_d.h"
+#include "catalog/pg_type.h"
#include "executor/executor.h"
+#include "executor/spi.h"
+#include "funcapi.h"
#include "pgstat.h"
#include "replication/conflict.h"
#include "replication/worker_internal.h"
#include "storage/lmgr.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/lsyscache.h"
+#include "utils/pg_lsn.h"
static const char *const ConflictTypeNames[] = {
[CT_INSERT_EXISTS] = "insert_exists",
@@ -50,8 +61,24 @@ static char *build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *localslot,
TupleTableSlot *remoteslot,
Oid indexoid);
+static void build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull);
static char *build_index_value_desc(EState *estate, Relation localrel,
TupleTableSlot *slot, Oid indexoid);
+static Datum tuple_table_slot_to_json_datum(TupleTableSlot *slot);
+static Datum tuple_table_slot_to_ri_json_datum(EState *estate,
+ Relation localrel,
+ Oid replica_index,
+ TupleTableSlot *slot);
+static HeapTuple prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ TransactionId local_xid, TimestampTz local_ts,
+ ConflictType conflict_type, RepOriginId origin_id,
+ TupleTableSlot *searchslot,
+ TupleTableSlot *localslot,
+ TupleTableSlot *remoteslot);
/*
* Get the xmin and commit timestamp data (origin and timestamp) associated
@@ -106,12 +133,15 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
TupleTableSlot *remoteslot, List *conflicttuples)
{
Relation localrel = relinfo->ri_RelationDesc;
+ Relation conflictlogrel = GetConflictLogTableRel();
StringInfoData err_detail;
+ HeapTuple conflictlogtuple;
initStringInfo(&err_detail);
/* Form errdetail message by combining conflicting tuples information. */
foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
+ {
errdetail_apply_conflict(estate, relinfo, type, searchslot,
conflicttuple->slot, remoteslot,
conflicttuple->indexoid,
@@ -120,6 +150,35 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
conflicttuple->ts,
&err_detail);
+ /* Insert conflict details to conflict log table. */
+ if (conflictlogrel)
+ {
+ /*
+ * Prepare the conflict log tuple. If the error level is below
+ * ERROR, insert it immediately. Otherwise, defer the insertion to
+ * a new transaction after the current one aborts, ensuring the log
+ * tuple is not rolled back.
+ */
+ conflictlogtuple = prepare_conflict_log_tuple(estate,
+ relinfo->ri_RelationDesc,
+ conflictlogrel,
+ conflicttuple->xmin,
+ conflicttuple->ts, type,
+ conflicttuple->origin,
+ searchslot, conflicttuple->slot,
+ remoteslot);
+ if (elevel < ERROR)
+ {
+ InsertConflictLogTuple(conflictlogrel, conflictlogtuple);
+ heap_freetuple(conflictlogtuple);
+ }
+ else
+ MyLogicalRepWorker->conflict_log_tuple = conflictlogtuple;
+
+ table_close(conflictlogrel, AccessExclusiveLock);
+ }
+ }
+
pgstat_report_subscription_conflict(MySubscription->oid, type);
ereport(elevel,
@@ -162,6 +221,61 @@ InitConflictIndexes(ResultRelInfo *relInfo)
relInfo->ri_onConflictArbiterIndexes = uniqueIndexes;
}
+/*
+ * GetConflictLogTableRel
+ *
+ * Get the information of the specific conflict log table defined in
+ * pg_subscription and opens the relation for insertion. The caller is
+ * responsible for closing the returned relation handle.
+ */
+Relation
+GetConflictLogTableRel(void)
+{
+ Oid nspid;
+ Oid conflictlogrelid;
+ Relation conflictlogrel = NULL;
+ char *conflictlogtable;
+
+ /* If conflict log table is not set for the subscription just return. */
+ conflictlogtable = get_subscription_conflict_log_table(
+ MyLogicalRepWorker->subid, &nspid);
+ if (conflictlogtable == NULL)
+ {
+ pfree(conflictlogtable);
+ return NULL;
+ }
+
+ conflictlogrelid = get_relname_relid(conflictlogtable, nspid);
+ if (OidIsValid(conflictlogrelid))
+ conflictlogrel = table_open(conflictlogrelid, RowExclusiveLock);
+
+ /* Conflict log table is dropped or not accessible. */
+ if (conflictlogrel == NULL)
+ ereport(WARNING,
+ (errcode(ERRCODE_UNDEFINED_TABLE),
+ errmsg("conflict log table \"%s.%s\" does not exist",
+ get_namespace_name(nspid), conflictlogtable)));
+
+ pfree(conflictlogtable);
+
+ return conflictlogrel;
+}
+
+/*
+ * InsertConflictLogTuple
+ *
+ * Persistently records the input conflict log tuple into the conflict log
+ * table. It uses HEAP_INSERT_NO_LOGICAL to explicitly block logical decoding
+ * of the tuple inserted into the conflict log table.
+ */
+void
+InsertConflictLogTuple(Relation conflictlogrel, HeapTuple tup)
+{
+ int options = HEAP_INSERT_NO_LOGICAL;
+
+ heap_insert(conflictlogrel, tup, GetCurrentCommandId(true), options, NULL);
+}
+
/*
* Add SQLSTATE error code to the current conflict report.
*/
@@ -472,6 +586,40 @@ build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
return tuple_value.data;
}
+/*
+ * Helper function to extract the "raw" index key Datums and their null flags
+ * from a TupleTableSlot, given an already open index descriptor.
+ * This is the reusable core logic.
+ */
+static void
+build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull)
+{
+ TupleTableSlot *tableslot = slot;
+
+ /*
+ * If the slot is a virtual slot, copy it into a heap tuple slot as
+ * FormIndexDatum only works with heap tuple slots.
+ */
+ if (TTS_IS_VIRTUAL(slot))
+ {
+ /* Slot is created within the EState's tuple table */
+ tableslot = table_slot_create(localrel, &estate->es_tupleTable);
+ tableslot = ExecCopySlot(tableslot, slot);
+ }
+
+ /*
+ * Initialize ecxt_scantuple for potential use in FormIndexDatum
+ */
+ GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+
+ /* Form the index datums */
+ FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values,
+ isnull);
+}
+
/*
* Helper functions to construct a string describing the contents of an index
* entry. See BuildIndexValueDescription for details.
@@ -487,41 +635,202 @@ build_index_value_desc(EState *estate, Relation localrel, TupleTableSlot *slot,
Relation indexDesc;
Datum values[INDEX_MAX_KEYS];
bool isnull[INDEX_MAX_KEYS];
- TupleTableSlot *tableslot = slot;
- if (!tableslot)
+ if (!slot)
return NULL;
Assert(CheckRelationOidLockedByMe(indexoid, RowExclusiveLock, true));
indexDesc = index_open(indexoid, NoLock);
- /*
- * If the slot is a virtual slot, copy it into a heap tuple slot as
- * FormIndexDatum only works with heap tuple slots.
- */
- if (TTS_IS_VIRTUAL(slot))
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+
+ index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+
+ index_close(indexDesc, NoLock);
+
+ return index_value;
+}
+
+/*
+ * tuple_table_slot_to_json_datum
+ *
+ * Helper function to convert a TupleTableSlot to Jsonb.
+ */
+static Datum
+tuple_table_slot_to_json_datum(TupleTableSlot *slot)
+{
+ HeapTuple tuple;
+ Datum datum;
+ Datum json;
+
+ Assert(slot != NULL);
+
+ tuple = ExecCopySlotHeapTuple(slot);
+ datum = heap_copy_tuple_as_datum(tuple, slot->tts_tupleDescriptor);
+
+ json = DirectFunctionCall1(row_to_json, datum);
+ heap_freetuple(tuple);
+
+ return json;
+}
+
+/*
+ * tuple_table_slot_to_ri_json_datum
+ *
+ * Fetch replica identity key from the tuple table slot and convert into a
+ * jsonb datum.
+ */
+static Datum
+tuple_table_slot_to_ri_json_datum(EState *estate, Relation localrel,
+ Oid replica_index, TupleTableSlot *slot)
+{
+ Relation indexDesc;
+ Datum values[INDEX_MAX_KEYS];
+ bool isnull[INDEX_MAX_KEYS];
+ HeapTuple tuple;
+ TupleDesc tupdesc;
+ Datum datum;
+
+ Assert(slot != NULL);
+
+ Assert(CheckRelationOidLockedByMe(replica_index, RowExclusiveLock, true));
+
+ indexDesc = index_open(replica_index, NoLock);
+
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+ tupdesc = RelationGetDescr(indexDesc);
+
+ /* Bless the tupdesc so it can be looked up by row_to_json. */
+ BlessTupleDesc(tupdesc);
+
+ /* Form the replica identity tuple. */
+ tuple = heap_form_tuple(tupdesc, values, isnull);
+ datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ index_close(indexDesc, NoLock);
+
+ /* Convert to a JSONB datum. */
+ return DirectFunctionCall1(row_to_json, datum);
+}
+
+/*
+ * prepare_conflict_log_tuple
+ *
+ * This routine prepares a tuple detailing a conflict encountered during
+ * logical replication. The prepared tuple will be inserted into the conflict
+ * log table by calling InsertConflictLogTuple.
+ *
+ * The caller is responsible for explicitly freeing the returned heap tuple
+ * after inserting.
+ */
+static HeapTuple
+prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ TransactionId local_xid, TimestampTz local_ts,
+ ConflictType conflict_type, RepOriginId origin_id,
+ TupleTableSlot *searchslot,
+ TupleTableSlot *localslot,
+ TupleTableSlot *remoteslot)
+{
+#define MAX_CONFLICT_ATTR_NUM 15
+ Datum values[MAX_CONFLICT_ATTR_NUM];
+ bool nulls[MAX_CONFLICT_ATTR_NUM];
+ int attno;
+ char *origin = NULL;
+ char *remote_origin = NULL;
+ HeapTuple tup;
+ MemoryContext oldctx;
+
+ /* Initialize values and nulls arrays. */
+ memset(values, 0, sizeof(Datum) * MAX_CONFLICT_ATTR_NUM);
+ memset(nulls, 0, sizeof(bool) * MAX_CONFLICT_ATTR_NUM);
+
+ /* Populate the values and nulls arrays. */
+ attno = 0;
+ values[attno++] = ObjectIdGetDatum(RelationGetRelid(rel));
+
+ values[attno++] =
+ CStringGetTextDatum(get_namespace_name(RelationGetNamespace(rel)));
+
+ values[attno++] = CStringGetTextDatum(RelationGetRelationName(rel));
+
+ values[attno++] = CStringGetTextDatum(ConflictTypeNames[conflict_type]);
+
+ if (TransactionIdIsValid(local_xid))
+ values[attno++] = TransactionIdGetDatum(local_xid);
+ else
+ nulls[attno++] = true;
+
+ if (TransactionIdIsValid(remote_xid))
+ values[attno++] = TransactionIdGetDatum(remote_xid);
+ else
+ nulls[attno++] = true;
+
+ values[attno++] = LSNGetDatum(remote_final_lsn);
+
+ if (local_ts > 0)
+ values[attno++] = TimestampTzGetDatum(local_ts);
+ else
+ nulls[attno++] = true;
+
+ if (remote_commit_ts > 0)
+ values[attno++] = TimestampTzGetDatum(remote_commit_ts);
+ else
+ nulls[attno++] = true;
+
+ if (origin_id != InvalidRepOriginId)
+ replorigin_by_oid(origin_id, true, &origin);
+
+ if (origin != NULL)
+ values[attno++] = CStringGetTextDatum(origin);
+ else
+ nulls[attno++] = true;
+
+ if (replorigin_session_origin != InvalidRepOriginId)
+ replorigin_by_oid(replorigin_session_origin, true, &remote_origin);
+
+ if (remote_origin != NULL)
+ values[attno++] = CStringGetTextDatum(remote_origin);
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(searchslot))
{
- tableslot = table_slot_create(localrel, &estate->es_tupleTable);
- tableslot = ExecCopySlot(tableslot, slot);
+ Oid replica_index = GetRelationIdentityOrPK(rel);
+
+ /*
+ * If the table has a valid replica identity index, build the index
+ * json datum from key value. Otherwise, construct it from the complete
+ * tuple in REPLICA IDENTITY FULL cases.
+ */
+ if (OidIsValid(replica_index))
+ values[attno++] = tuple_table_slot_to_ri_json_datum(estate, rel,
+ replica_index,
+ searchslot);
+ else
+ values[attno++] = tuple_table_slot_to_json_datum(searchslot);
}
+ else
+ nulls[attno++] = true;
- /*
- * Initialize ecxt_scantuple for potential use in FormIndexDatum when
- * index expressions are present.
- */
- GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+ if (!TupIsNull(localslot))
+ values[attno++] = tuple_table_slot_to_json_datum(localslot);
+ else
+ nulls[attno++] = true;
- /*
- * The values/nulls arrays passed to BuildIndexValueDescription should be
- * the results of FormIndexDatum, which are the "raw" input to the index
- * AM.
- */
- FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values, isnull);
+ if (!TupIsNull(remoteslot))
+ values[attno++] = tuple_table_slot_to_json_datum(remoteslot);
+ else
+ nulls[attno++] = true;
- index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+ Assert(attno + 1 == MAX_CONFLICT_ATTR_NUM);
- index_close(indexDesc, NoLock);
+ oldctx = MemoryContextSwitchTo(ApplyContext);
+ tup = heap_form_tuple(RelationGetDescr(conflictlogrel), values, nulls);
+ MemoryContextSwitchTo(oldctx);
- return index_value;
+ return tup;
}
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 93970c6af29..1a5dd5cfac3 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -482,7 +482,9 @@ static bool MySubscriptionValid = false;
static List *on_commit_wakeup_workers_subids = NIL;
bool in_remote_transaction = false;
-static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+TransactionId remote_xid = InvalidTransactionId;
+TimestampTz remote_commit_ts = 0;
/* fields valid only when processing streamed transaction */
static bool in_streamed_transaction = false;
@@ -1219,6 +1221,8 @@ apply_handle_begin(StringInfo s)
set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
remote_final_lsn = begin_data.final_lsn;
+ remote_commit_ts = begin_data.committime;
+ remote_xid = begin_data.xid;
maybe_start_skipping_changes(begin_data.final_lsn);
@@ -1745,6 +1749,10 @@ apply_handle_stream_start(StringInfo s)
/* extract XID of the top-level transaction */
stream_xid = logicalrep_read_stream_start(s, &first_segment);
+ remote_xid = stream_xid;
+ remote_final_lsn = InvalidXLogRecPtr;
+ remote_commit_ts = 0;
+
if (!TransactionIdIsValid(stream_xid))
ereport(ERROR,
(errcode(ERRCODE_PROTOCOL_VIOLATION),
@@ -5609,6 +5617,28 @@ start_apply(XLogRecPtr origin_startpos)
pgstat_report_subscription_error(MySubscription->oid,
MyLogicalRepWorker->type);
+ /*
+ * Insert the pending conflict log tuple under a new transaction.
+ */
+ if (MyLogicalRepWorker->conflict_log_tuple != NULL)
+ {
+ Relation conflictlogrel;
+
+ StartTransactionCommand();
+ PushActiveSnapshot(GetTransactionSnapshot());
+
+ /* Open conflict log table. */
+ conflictlogrel = GetConflictLogTableRel();
+ InsertConflictLogTuple(conflictlogrel,
+ MyLogicalRepWorker->conflict_log_tuple);
+ heap_freetuple(MyLogicalRepWorker->conflict_log_tuple);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+ table_close(conflictlogrel, AccessExclusiveLock);
+
+ PopActiveSnapshot();
+ CommitTransactionCommand();
+ }
+
PG_RE_THROW();
}
}
diff --git a/src/backend/utils/cache/lsyscache.c b/src/backend/utils/cache/lsyscache.c
index fa7cd7e06a7..12db6676406 100644
--- a/src/backend/utils/cache/lsyscache.c
+++ b/src/backend/utils/cache/lsyscache.c
@@ -3881,3 +3881,41 @@ get_subscription_name(Oid subid, bool missing_ok)
return subname;
}
+
+/*
+ * get_subscription_conflict_log_table
+ *
+ * Get conflict log table name and namespace id from subscription.
+ */
+char *
+get_subscription_conflict_log_table(Oid subid, Oid *nspid)
+{
+ HeapTuple tup;
+ Datum datum;
+ bool isnull;
+ char *relname = NULL;
+ Form_pg_subscription subform;
+
+ *nspid = InvalidOid;
+
+ tup = SearchSysCache1(SUBSCRIPTIONOID, ObjectIdGetDatum(subid));
+
+ if (!HeapTupleIsValid(tup))
+ return NULL;
+
+ subform = (Form_pg_subscription) GETSTRUCT(tup);
+
+ /* Get conflict log table name. */
+ datum = SysCacheGetAttr(SUBSCRIPTIONOID,
+ tup,
+ Anum_pg_subscription_subconflictlogtable,
+ &isnull);
+ if (!isnull)
+ {
+ *nspid = subform->subconflictlognspid;
+ relname = pstrdup(TextDatumGetCString(datum));
+ }
+
+ ReleaseSysCache(tup);
+ return relname;
+}
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index 36f24502842..c18ec248e5d 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -6806,7 +6806,7 @@ describeSubscriptions(const char *pattern, bool verbose)
printQueryOpt myopt = pset.popt;
static const bool translate_columns[] = {false, false, false, false,
false, false, false, false, false, false, false, false, false, false,
- false, false, false, false};
+ false, false, false, false, false};
if (pset.sversion < 100000)
{
@@ -6900,6 +6900,12 @@ describeSubscriptions(const char *pattern, bool verbose)
appendPQExpBuffer(&buf,
", subskiplsn AS \"%s\"\n",
gettext_noop("Skip LSN"));
+
+ /* Conflict log table is only supported in v19 and higher */
+ if (pset.sversion >= 190000)
+ appendPQExpBuffer(&buf,
+ ", subconflictlogtable AS \"%s\"\n",
+ gettext_noop("Conflict log table"));
}
/* Only display subscriptions in current database. */
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 51806597037..4057c0a22b4 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -2344,8 +2344,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("(", "PUBLICATION");
/* ALTER SUBSCRIPTION <name> SET ( */
else if (Matches("ALTER", "SUBSCRIPTION", MatchAny, MatchAnyN, "SET", "("))
- COMPLETE_WITH("binary", "disable_on_error", "failover",
- "max_retention_duration", "origin",
+ COMPLETE_WITH("binary", "conflict_log_table", "disable_on_error",
+ "failover", "max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
"synchronous_commit", "two_phase");
@@ -3814,8 +3814,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("WITH (");
/* Complete "CREATE SUBSCRIPTION <name> ... WITH ( <opt>" */
else if (Matches("CREATE", "SUBSCRIPTION", MatchAnyN, "WITH", "("))
- COMPLETE_WITH("binary", "connect", "copy_data", "create_slot",
- "disable_on_error", "enabled", "failover",
+ COMPLETE_WITH("binary", "conflict_log_table", "connect", "copy_data",
+ "create_slot", "disable_on_error", "enabled", "failover",
"max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index 55cb9b1eefa..f4526c15ec3 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -80,6 +80,8 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
bool subretaindeadtuples; /* True if dead tuples useful for
* conflict detection are retained */
+ Oid subconflictlognspid; /* Namespace Oid in which the conflict
+ * log table is created. */
int32 submaxretention; /* The maximum duration (in milliseconds)
* for which information useful for
@@ -105,6 +107,9 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
/* Only publish data originating from the specified origin */
text suborigin BKI_DEFAULT(LOGICALREP_ORIGIN_ANY);
+
+ /* Conflict log table name if specified */
+ text subconflictlogtable;
#endif
} FormData_pg_subscription;
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index c8fbf9e51b8..7997dd83c91 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -9,9 +9,11 @@
#ifndef CONFLICT_H
#define CONFLICT_H
+#include "access/htup.h"
#include "access/xlogdefs.h"
#include "nodes/pg_list.h"
#include "utils/timestamp.h"
+#include "utils/relcache.h"
/* Avoid including execnodes.h here */
typedef struct EState EState;
@@ -89,4 +91,6 @@ extern void ReportApplyConflict(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *remoteslot,
List *conflicttuples);
extern void InitConflictIndexes(ResultRelInfo *relInfo);
+extern Relation GetConflictLogTableRel(void);
+extern void InsertConflictLogTuple(Relation conflictlogrel, HeapTuple tup);
#endif
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index f081619f151..6002b3502bb 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -101,6 +101,9 @@ typedef struct LogicalRepWorker
*/
TransactionId oldest_nonremovable_xid;
+ /* Store conflict log tuple to be inserted before worker exit. */
+ HeapTuple conflict_log_tuple;
+
/* Stats. */
XLogRecPtr last_lsn;
TimestampTz last_send_time;
@@ -256,6 +259,10 @@ extern PGDLLIMPORT bool InitializingApplyWorker;
extern PGDLLIMPORT List *table_states_not_ready;
+extern XLogRecPtr remote_final_lsn;
+extern TimestampTz remote_commit_ts;
+extern TransactionId remote_xid;
+
extern void logicalrep_worker_attach(int slot);
extern LogicalRepWorker *logicalrep_worker_find(LogicalRepWorkerType wtype,
Oid subid, Oid relid,
diff --git a/src/include/utils/lsyscache.h b/src/include/utils/lsyscache.h
index 50fb149e9ac..3bebf04bf51 100644
--- a/src/include/utils/lsyscache.h
+++ b/src/include/utils/lsyscache.h
@@ -210,6 +210,7 @@ extern Oid get_publication_oid(const char *pubname, bool missing_ok);
extern char *get_publication_name(Oid pubid, bool missing_ok);
extern Oid get_subscription_oid(const char *subname, bool missing_ok);
extern char *get_subscription_name(Oid subid, bool missing_ok);
+extern char *get_subscription_conflict_log_table(Oid subid, Oid *nspid);
#define type_is_array(typid) (get_element_type(typid) != InvalidOid)
/* type_is_array_domain accepts both plain arrays and domains over arrays */
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index 327d1e7731f..6a89784e1da 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -116,18 +116,18 @@ CREATE SUBSCRIPTION regress_testsub4 CONNECTION 'dbname=regress_doesnotexist' PU
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub4 SET (origin = any);
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
DROP SUBSCRIPTION regress_testsub3;
@@ -145,10 +145,10 @@ ALTER SUBSCRIPTION regress_testsub CONNECTION 'foobar';
ERROR: invalid connection string syntax: missing "=" after "foobar" in connection info string
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET PUBLICATION testpub2, testpub3 WITH (refresh = false);
@@ -157,10 +157,10 @@ ALTER SUBSCRIPTION regress_testsub SET (slot_name = 'newname');
ALTER SUBSCRIPTION regress_testsub SET (password_required = false);
ALTER SUBSCRIPTION regress_testsub SET (run_as_owner = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (password_required = true);
@@ -176,10 +176,10 @@ ERROR: unrecognized subscription parameter: "create_slot"
-- ok
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/12345');
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345 |
(1 row)
-- ok - with lsn = NONE
@@ -188,10 +188,10 @@ ALTER SUBSCRIPTION regress_testsub SKIP (lsn = NONE);
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/0');
ERROR: invalid WAL location (LSN): 0/0
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 |
(1 row)
BEGIN;
@@ -223,10 +223,10 @@ ALTER SUBSCRIPTION regress_testsub_foo SET (synchronous_commit = foobar);
ERROR: invalid value for parameter "synchronous_commit": "foobar"
HINT: Available values: local, remote_write, remote_apply, on, off.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
----------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+---------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000 |
(1 row)
-- rename back to keep the rest simple
@@ -255,19 +255,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (binary = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -279,27 +279,27 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = parallel);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- fail - publication already exists
@@ -314,10 +314,10 @@ ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refr
ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refresh = false);
ERROR: publication "testpub1" is already in subscription "regress_testsub"
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- fail - publication used more than once
@@ -332,10 +332,10 @@ ERROR: publication "testpub3" is not in subscription "regress_testsub"
-- ok - delete publications
ALTER SUBSCRIPTION regress_testsub DROP PUBLICATION testpub1, testpub2 WITH (refresh = false);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -371,19 +371,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- we can alter streaming when two_phase enabled
ALTER SUBSCRIPTION regress_testsub SET (streaming = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -393,10 +393,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -409,18 +409,18 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (disable_on_error = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -433,10 +433,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -450,19 +450,19 @@ NOTICE: max_retention_duration is ineffective when retain_dead_tuples is disabl
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- ok
ALTER SUBSCRIPTION regress_testsub SET (max_retention_duration = 0);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -517,6 +517,129 @@ COMMIT;
-- ok, owning it is enough for this stuff
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG TABLE TESTS
+--
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+-- fail - conflict_log_table specified when table already exists
+CREATE TABLE public.regress_conflict_log_temp (id int);
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log_temp');
+ERROR: cannot create conflict log table "public.regress_conflict_log_temp" because a table with that name already exists
+HINT: Use a different name for the conflict log table or drop the existing table.
+DROP TABLE public.regress_conflict_log_temp;
+-- ok - conflict_log_table creation with CREATE SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- check metadata in pg_subscription
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname | subconflictlogtable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test1 | regress_conflict_log1 | t
+(1 row)
+
+-- check if the table exists and has the correct schema (15 columns)
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attnum > 0;
+ count
+-------
+ 14
+(1 row)
+
+-- check a specific column type (e.g., key_tuple should be JSON)
+SELECT format_type(atttypid, atttypmod) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attname = 'key_tuple';
+ format_type
+-------------
+ json
+(1 row)
+
+\dRs+
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+-----------------------
+ regress_conflict_test1 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | regress_conflict_log1
+(1 row)
+
+-- ok - adding conflict_log_table with ALTER SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log2');
+-- check metadata after ALTER
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogtable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test2 | regress_conflict_log2 | t
+(1 row)
+
+-- ok - change the conflict log table name for an existing subscription that already had one
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log3');
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogtable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test2 | regress_conflict_log3 | t
+(1 row)
+
+\dRs+
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+-----------------------
+ regress_conflict_test1 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | regress_conflict_log1
+ regress_conflict_test2 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | regress_conflict_log3
+(2 rows)
+
+-- check the new table was created and the old table was dropped
+SELECT count(*) FROM pg_class WHERE relname = 'regress_conflict_log2';
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log3'::regclass AND attnum > 0;
+ count
+-------
+ 14
+(1 row)
+
+-- ok (NOTICE) - set conflict_log_table to one already used by this subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log3');
+NOTICE: "public.regress_conflict_log3" is already in use as the conflict log table for this subscription
+-- fail - set conflict_log_table to one already used by a different subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log1');
+ERROR: cannot create conflict log table "public.regress_conflict_log1" because a table with that name already exists
+HINT: Use a different name for the conflict log table or drop the existing table.
+-- ok - dropping subscription also drops the log table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the table was dropped
+SELECT to_regclass('public.regress_conflict_log1');
+ to_regclass
+-------------
+
+(1 row)
+
+-- ok - dropping subscription when the log table was manually dropped first
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+DROP TABLE public.regress_conflict_log1;
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+NOTICE: table "regress_conflict_log1" does not exist, skipping
+-- should return NULL, meaning the subscription was dropped successfully
+SELECT subname FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname
+---------
+(0 rows)
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
RESET SESSION AUTHORIZATION;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index ef0c298d2df..a78ccbc3c01 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -365,6 +365,79 @@ COMMIT;
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG TABLE TESTS
+--
+
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+
+-- fail - conflict_log_table specified when table already exists
+CREATE TABLE public.regress_conflict_log_temp (id int);
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log_temp');
+DROP TABLE public.regress_conflict_log_temp;
+
+-- ok - conflict_log_table creation with CREATE SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+
+-- check metadata in pg_subscription
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- check if the table exists and has the correct schema (15 columns)
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attnum > 0;
+
+-- check a specific column type (e.g., key_tuple should be JSON)
+SELECT format_type(atttypid, atttypmod) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attname = 'key_tuple';
+
+\dRs+
+
+-- ok - adding conflict_log_table with ALTER SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log2');
+
+-- check metadata after ALTER
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- ok - change the conflict log table name for an existing subscription that already had one
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log3');
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+\dRs+
+
+-- check the new table was created and the old table was dropped
+SELECT count(*) FROM pg_class WHERE relname = 'regress_conflict_log2';
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log3'::regclass AND attnum > 0;
+
+-- ok (NOTICE) - set conflict_log_table to one already used by this subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log3');
+
+-- fail - set conflict_log_table to one already used by a different subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log1');
+
+-- ok - dropping subscription also drops the log table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the table was dropped
+SELECT to_regclass('public.regress_conflict_log1');
+
+-- ok - dropping subscription when the log table was manually dropped first
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+DROP TABLE public.regress_conflict_log1;
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the subscription was dropped successfully
+SELECT subname FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+
RESET SESSION AUTHORIZATION;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
--
2.49.0
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-11-27 00:59 Peter Smith <smithpb2250@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Peter Smith @ 2025-11-27 00:59 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Hi Dilip. Some review comments for v7-0001.
======
src/backend/replication/logical/conflict.c
1.
+ /* Insert conflict details to conflict log table. */
+ if (conflictlogrel)
+ {
+ /*
+ * Prepare the conflict log tuple. If the error level is below
+ * ERROR, insert it immediately. Otherwise, defer the insertion to
+ * a new transaction after the current one aborts, ensuring the log
+ * tuple is not rolled back.
+ */
+ conflictlogtuple = prepare_conflict_log_tuple(estate,
+ relinfo->ri_RelationDesc,
+ conflictlogrel,
+ conflicttuple->xmin,
+ conflicttuple->ts, type,
+ conflicttuple->origin,
+ searchslot, conflicttuple->slot,
+ remoteslot);
+ if (elevel < ERROR)
+ {
+ InsertConflictLogTuple(conflictlogrel, conflictlogtuple);
+ heap_freetuple(conflictlogtuple);
+ }
+ else
+ MyLogicalRepWorker->conflict_log_tuple = conflictlogtuple;
+
+ table_close(conflictlogrel, AccessExclusiveLock);
+ }
+ }
+
IMO, some refactoring would help simplify conflictlogtuple processing. e.g.
i) You don't need any separate 'conflictlogtuple' var
- Use MyLogicalRepWorker->conflict_log_tuple always for this purpose
ii) prepare_conflict_log_tuple()
- Change this to a void; it will always side-effect
MyLogicalRepWorker->conflict_log_tuple
- Assert MyLogicalRepWorker->conflict_log_tuple must be NULL on entry
iii) InsertConflictLogTuple()
- The 2nd param it not needed if you always use
MyLogicalRepWorker->conflict_log_tuple
- Asserts MyLogicalRepWorker->conflict_log_tuple is not NULL, then writes it
- BTW, I felt that heap_freetuple could also be done here too
- Finally, sets to MyLogicalRepWorker->conflict_log_tuple to NULL
(ready for the next conflict)
~~~
InsertConflictLogTuple:
2.
+/*
+ * InsertConflictLogTuple
+ *
+ * Persistently records the input conflict log tuple into the conflict log
+ * table. It uses HEAP_INSERT_NO_LOGICAL to explicitly block logical decoding
+ * of the tuple inserted into the conflict log table.
+ */
+void
+InsertConflictLogTuple(Relation conflictlogrel, HeapTuple tup)
+{
+ int options = HEAP_INSERT_NO_LOGICAL;
+
+ heap_insert(conflictlogrel, tup, GetCurrentCommandId(true), options, NULL);
+}
See the above review comment (iii), for some suggested changes to this function.
~~~
prepare_conflict_log_tuple:
3.
+ * The caller is responsible for explicitly freeing the returned heap tuple
+ * after inserting.
+ */
+static HeapTuple
+prepare_conflict_log_tuple(EState *estate, Relation rel,
As per the above review comment (iii), I thought the Insert function
could handle the freeing.
~~~
4.
+ oldctx = MemoryContextSwitchTo(ApplyContext);
+ tup = heap_form_tuple(RelationGetDescr(conflictlogrel), values, nulls);
+ MemoryContextSwitchTo(oldctx);
- return index_value;
+ return tup;
Per the above comment (ii), change this to assign to
MyLogicalRepWorker->conflict_log_tuple.
======
src/backend/replication/logical/worker.c
start_apply:
5.
+ /*
+ * Insert the pending conflict log tuple under a new transaction.
+ */
/Insert the/Insert any/
~~~
6.
+ InsertConflictLogTuple(conflictlogrel,
+ MyLogicalRepWorker->conflict_log_tuple);
+ heap_freetuple(MyLogicalRepWorker->conflict_log_tuple);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
Per earlier reqview comment (iii), remove the 2nd parm to
InsertConflictLogTuple, and those other 2 statements can also be
handled within InsertConflictLogTuple.
======
src/include/replication/worker_internal.h
7.
+ /* Store conflict log tuple to be inserted before worker exit. */
+ HeapTuple conflict_log_tuple;
+
Per my above suggestions, this member comment becomes something more
like "A conflict log tuple which is prepared but not yet written. */
======
Kind Regards,
Peter Smith.
Fujitsu Australia
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-11-27 12:20 Dilip Kumar <dilipbalaut@gmail.com>
parent: Peter Smith <smithpb2250@gmail.com>
0 siblings, 3 replies; 912+ messages in thread
From: Dilip Kumar @ 2025-11-27 12:20 UTC (permalink / raw)
To: Peter Smith <smithpb2250@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, Nov 27, 2025 at 6:30 AM Peter Smith <smithpb2250@gmail.com> wrote:
>
> Hi Dilip. Some review comments for v7-0001.
>
> ======
> src/backend/replication/logical/conflict.c
>
> 1.
> + /* Insert conflict details to conflict log table. */
> + if (conflictlogrel)
> + {
> + /*
> + * Prepare the conflict log tuple. If the error level is below
> + * ERROR, insert it immediately. Otherwise, defer the insertion to
> + * a new transaction after the current one aborts, ensuring the log
> + * tuple is not rolled back.
> + */
> + conflictlogtuple = prepare_conflict_log_tuple(estate,
> + relinfo->ri_RelationDesc,
> + conflictlogrel,
> + conflicttuple->xmin,
> + conflicttuple->ts, type,
> + conflicttuple->origin,
> + searchslot, conflicttuple->slot,
> + remoteslot);
> + if (elevel < ERROR)
> + {
> + InsertConflictLogTuple(conflictlogrel, conflictlogtuple);
> + heap_freetuple(conflictlogtuple);
> + }
> + else
> + MyLogicalRepWorker->conflict_log_tuple = conflictlogtuple;
> +
> + table_close(conflictlogrel, AccessExclusiveLock);
> + }
> + }
> +
>
> IMO, some refactoring would help simplify conflictlogtuple processing. e.g.
>
> i) You don't need any separate 'conflictlogtuple' var
> - Use MyLogicalRepWorker->conflict_log_tuple always for this purpose
> ii) prepare_conflict_log_tuple()
> - Change this to a void; it will always side-effect
> MyLogicalRepWorker->conflict_log_tuple
> - Assert MyLogicalRepWorker->conflict_log_tuple must be NULL on entry
> iii) InsertConflictLogTuple()
> - The 2nd param it not needed if you always use
> MyLogicalRepWorker->conflict_log_tuple
> - Asserts MyLogicalRepWorker->conflict_log_tuple is not NULL, then writes it
> - BTW, I felt that heap_freetuple could also be done here too
> - Finally, sets to MyLogicalRepWorker->conflict_log_tuple to NULL
> (ready for the next conflict)
>
> ~~~
>
> InsertConflictLogTuple:
>
> 2.
> +/*
> + * InsertConflictLogTuple
> + *
> + * Persistently records the input conflict log tuple into the conflict log
> + * table. It uses HEAP_INSERT_NO_LOGICAL to explicitly block logical decoding
> + * of the tuple inserted into the conflict log table.
> + */
> +void
> +InsertConflictLogTuple(Relation conflictlogrel, HeapTuple tup)
> +{
> + int options = HEAP_INSERT_NO_LOGICAL;
> +
> + heap_insert(conflictlogrel, tup, GetCurrentCommandId(true), options, NULL);
> +}
>
> See the above review comment (iii), for some suggested changes to this function.
>
> ~~~
>
> prepare_conflict_log_tuple:
>
> 3.
> + * The caller is responsible for explicitly freeing the returned heap tuple
> + * after inserting.
> + */
> +static HeapTuple
> +prepare_conflict_log_tuple(EState *estate, Relation rel,
>
> As per the above review comment (iii), I thought the Insert function
> could handle the freeing.
>
> ~~~
>
> 4.
> + oldctx = MemoryContextSwitchTo(ApplyContext);
> + tup = heap_form_tuple(RelationGetDescr(conflictlogrel), values, nulls);
> + MemoryContextSwitchTo(oldctx);
>
> - return index_value;
> + return tup;
>
> Per the above comment (ii), change this to assign to
> MyLogicalRepWorker->conflict_log_tuple.
>
> ======
> src/backend/replication/logical/worker.c
>
> start_apply:
>
> 5.
> + /*
> + * Insert the pending conflict log tuple under a new transaction.
> + */
>
> /Insert the/Insert any/
>
> ~~~
>
> 6.
> + InsertConflictLogTuple(conflictlogrel,
> + MyLogicalRepWorker->conflict_log_tuple);
> + heap_freetuple(MyLogicalRepWorker->conflict_log_tuple);
> + MyLogicalRepWorker->conflict_log_tuple = NULL;
>
> Per earlier reqview comment (iii), remove the 2nd parm to
> InsertConflictLogTuple, and those other 2 statements can also be
> handled within InsertConflictLogTuple.
>
> ======
> src/include/replication/worker_internal.h
>
> 7.
> + /* Store conflict log tuple to be inserted before worker exit. */
> + HeapTuple conflict_log_tuple;
> +
>
> Per my above suggestions, this member comment becomes something more
> like "A conflict log tuple which is prepared but not yet written. */
>
I have fixed all these comments and also the comments of 0002, now I
feel we can actually merge 0001 and 0002, so I have merged both of
them.
Now pending work status
1) fixed review comments of 0003
2) Run pgindent -- planning to do it after we complete the first level
of review
3) Subscription TAP test for logging the actual conflicts
--
Regards,
Dilip Kumar
Google
Attachments:
[application/octet-stream] v8-0001-Add-configurable-conflict-log-table-for-Logical-R.patch (109.7K, ../../CAFiTN-uTsLsEGqTanb6tWeKYaE-pgEUXCEx7cnzSYc1LAWCeKg@mail.gmail.com/2-v8-0001-Add-configurable-conflict-log-table-for-Logical-R.patch)
download | inline diff:
From e6ec7862f7ef760f5a489dab28d8dcab1cfc02dd Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 12 Nov 2025 10:43:19 +0530
Subject: [PATCH v8] Add configurable conflict log table for Logical
Replication
This patch adds a feature to provide a structured, queryable record of all
logical replication conflicts. The current approach of logging conflicts as
plain text in the server logs makes it difficult to query, analyze, and
use for external monitoring and automation.
This patch addresses these limitations by introducing a configurable
conflict_log_table option in the CREATE SUBSCRIPTION command. Key design
decisions include:
User-Managed Table: The conflict log is stored in a user-managed table
rather than a system catalog.
Structured Data: Conflict details, including the original and remote tuples,
are stored in JSON columns, providing a flexible format to accommodate different
table schemas.
Comprehensive Information: The log table captures essential attributes such as
local and remote transaction IDs, LSNs, commit timestamps, and conflict type,
providing a complete record for post-mortem analysis.
This feature will make logical replication conflicts easier to monitor and manage,
significantly improving the overall resilience and operability of replication setups.
---
src/backend/catalog/pg_publication.c | 25 +-
src/backend/commands/subscriptioncmds.c | 239 +++++++++++++-
src/backend/replication/logical/conflict.c | 360 +++++++++++++++++++--
src/backend/replication/logical/launcher.c | 1 +
src/backend/replication/logical/worker.c | 30 +-
src/backend/utils/cache/lsyscache.c | 38 +++
src/bin/psql/describe.c | 8 +-
src/bin/psql/tab-complete.in.c | 8 +-
src/include/catalog/pg_subscription.h | 5 +
src/include/commands/subscriptioncmds.h | 2 +
src/include/replication/conflict.h | 4 +
src/include/replication/worker_internal.h | 7 +
src/include/utils/lsyscache.h | 1 +
src/test/regress/expected/subscription.out | 319 +++++++++++++-----
src/test/regress/sql/subscription.sql | 87 +++++
15 files changed, 1012 insertions(+), 122 deletions(-)
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index ac2f4ee3561..7e2f50cafd6 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -31,6 +31,7 @@
#include "catalog/pg_publication_rel.h"
#include "catalog/pg_type.h"
#include "commands/publicationcmds.h"
+#include "commands/subscriptioncmds.h"
#include "funcapi.h"
#include "utils/array.h"
#include "utils/builtins.h"
@@ -85,6 +86,14 @@ check_publication_add_relation(Relation targetrel)
errmsg("cannot add relation \"%s\" to publication",
RelationGetRelationName(targetrel)),
errdetail("This operation is not supported for unlogged tables.")));
+
+ /* Can't be conflict log table */
+ if (IsConflictLogRelid(RelationGetRelid(targetrel)))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot add relation \"%s\" to publication",
+ RelationGetRelationName(targetrel)),
+ errdetail("This operation is not supported for conflict log tables.")));
}
/*
@@ -145,6 +154,13 @@ is_publishable_class(Oid relid, Form_pg_class reltuple)
/*
* Another variant of is_publishable_class(), taking a Relation.
+ *
+ * Note: Conflict log tables are not publishable. However, we intentionally
+ * skip this check here because this function is called for every change and
+ * performing this check during every change publication is costly. To ensure
+ * unpublishable entries are ignored without incurring performance overhead,
+ * tuples inserted into the conflict log table uses the HEAP_INSERT_NO_LOGICAL
+ * flag. This allows the decoding layer to bypass these entries automatically.
*/
bool
is_publishable_relation(Relation rel)
@@ -169,7 +185,10 @@ pg_relation_is_publishable(PG_FUNCTION_ARGS)
tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
if (!HeapTupleIsValid(tuple))
PG_RETURN_NULL();
- result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple));
+
+ /* Subscription conflict log tables are not published */
+ result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple)) &&
+ !IsConflictLogRelid(relid);
ReleaseSysCache(tuple);
PG_RETURN_BOOL(result);
}
@@ -890,7 +909,9 @@ GetAllPublicationRelations(char relkind, bool pubviaroot)
Form_pg_class relForm = (Form_pg_class) GETSTRUCT(tuple);
Oid relid = relForm->oid;
+ /* Subscription conflict log tables are not published */
if (is_publishable_class(relid, relForm) &&
+ !IsConflictLogRelid(relid) &&
!(relForm->relispartition && pubviaroot))
result = lappend_oid(result, relid);
}
@@ -1018,7 +1039,7 @@ GetSchemaPublicationRelations(Oid schemaid, PublicationPartOpt pub_partopt)
Oid relid = relForm->oid;
char relkind;
- if (!is_publishable_class(relid, relForm))
+ if (!is_publishable_class(relid, relForm) || IsConflictLogRelid(relid))
continue;
relkind = get_rel_relkind(relid);
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index 24b70234b35..e96dee29851 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -15,6 +15,7 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/htup_details.h"
#include "access/table.h"
#include "access/twophase.h"
@@ -34,6 +35,7 @@
#include "commands/event_trigger.h"
#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "executor/spi.h"
#include "miscadmin.h"
#include "nodes/makefuncs.h"
#include "pgstat.h"
@@ -47,10 +49,12 @@
#include "storage/lmgr.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/guc.h"
#include "utils/lsyscache.h"
#include "utils/memutils.h"
#include "utils/pg_lsn.h"
+#include "utils/regproc.h"
#include "utils/syscache.h"
/*
@@ -75,6 +79,7 @@
#define SUBOPT_MAX_RETENTION_DURATION 0x00008000
#define SUBOPT_LSN 0x00010000
#define SUBOPT_ORIGIN 0x00020000
+#define SUBOPT_CONFLICT_LOG_TABLE 0x00040000
/* check if the 'val' has 'bits' set */
#define IsSet(val, bits) (((val) & (bits)) == (bits))
@@ -103,6 +108,7 @@ typedef struct SubOpts
bool retaindeadtuples;
int32 maxretention;
char *origin;
+ char *conflictlogtable;
XLogRecPtr lsn;
} SubOpts;
@@ -135,7 +141,8 @@ static List *merge_publications(List *oldpublist, List *newpublist, bool addpub,
static void ReportSlotConnectionError(List *rstates, Oid subid, char *slotname, char *err);
static void CheckAlterSubOption(Subscription *sub, const char *option,
bool slot_needs_update, bool isTopLevel);
-
+static void create_conflict_log_table(Oid namespaceId, char *conflictrel);
+static void drop_conflict_log_table(Oid namespaceId, char *conflictrel);
/*
* Common option parsing function for CREATE and ALTER SUBSCRIPTION commands.
@@ -191,6 +198,8 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->maxretention = 0;
if (IsSet(supported_opts, SUBOPT_ORIGIN))
opts->origin = pstrdup(LOGICALREP_ORIGIN_ANY);
+ if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_TABLE))
+ opts->conflictlogtable = NULL;
/* Parse options */
foreach(lc, stmt_options)
@@ -402,6 +411,15 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->specified_opts |= SUBOPT_LSN;
opts->lsn = lsn;
}
+ else if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_TABLE) &&
+ strcmp(defel->defname, "conflict_log_table") == 0)
+ {
+ if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_LOG_TABLE))
+ errorConflictingDefElem(defel, pstate);
+
+ opts->specified_opts |= SUBOPT_CONFLICT_LOG_TABLE;
+ opts->conflictlogtable = defGetString(defel);
+ }
else
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
@@ -599,6 +617,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bits32 supported_opts;
SubOpts opts = {0};
AclResult aclresult;
+ Oid conflictlogtable_nspid = InvalidOid;
+ char *conflictlogtable = NULL;
/*
* Parse and check options.
@@ -612,7 +632,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
SUBOPT_DISABLE_ON_ERR | SUBOPT_PASSWORD_REQUIRED |
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
- SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN);
+ SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_TABLE);
parse_subscription_options(pstate, stmt->options, supported_opts, &opts);
/*
@@ -747,6 +768,25 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
values[Anum_pg_subscription_suborigin - 1] =
CStringGetTextDatum(opts.origin);
+ /*
+ * If a conflict log table name is specified, parse the schema and table
+ * name from the string. Store the namespace OID and the table name in
+ * the pg_subscription catalog tuple.
+ */
+ if (opts.conflictlogtable)
+ {
+ List *names = stringToQualifiedNameList(opts.conflictlogtable, NULL);
+
+ conflictlogtable_nspid =
+ QualifiedNameGetCreationNamespace(names, &conflictlogtable);
+ values[Anum_pg_subscription_subconflictlognspid - 1] =
+ ObjectIdGetDatum(conflictlogtable_nspid);
+ values[Anum_pg_subscription_subconflictlogtable - 1] =
+ CStringGetTextDatum(conflictlogtable);
+ }
+ else
+ nulls[Anum_pg_subscription_subconflictlogtable - 1] = true;
+
tup = heap_form_tuple(RelationGetDescr(rel), values, nulls);
/* Insert tuple into catalog. */
@@ -768,6 +808,10 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
ReplicationOriginNameForLogicalRep(subid, InvalidOid, originname, sizeof(originname));
replorigin_create(originname);
+ /* If a conflict log table name is given then create the table. */
+ if (opts.conflictlogtable)
+ create_conflict_log_table(conflictlogtable_nspid, conflictlogtable);
+
/*
* Connect to remote side to execute requested commands and fetch table
* and sequence info.
@@ -1410,7 +1454,8 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
SUBOPT_MAX_RETENTION_DURATION |
- SUBOPT_ORIGIN);
+ SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_TABLE);
parse_subscription_options(pstate, stmt->options,
supported_opts, &opts);
@@ -1665,6 +1710,59 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
origin = opts.origin;
}
+ if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_LOG_TABLE))
+ {
+ Oid nspid;
+ char *relname = NULL;
+ Oid old_nspid = InvalidOid;
+ char *old_relname = NULL;
+ List *names =
+ stringToQualifiedNameList(opts.conflictlogtable, NULL);
+
+ nspid = QualifiedNameGetCreationNamespace(names, &relname);
+
+ /*
+ * If the subscription already uses this conflict log table
+ * and it exists, just issue a notice.
+ */
+ old_relname =
+ get_subscription_conflict_log_table(subid, &old_nspid);
+ if (old_relname != NULL &&
+ strcmp(old_relname, relname) == 0 &&
+ old_nspid == nspid &&
+ OidIsValid(get_relname_relid(relname, nspid)))
+ {
+ char *nspname = get_namespace_name(nspid);
+
+ ereport(NOTICE,
+ (errmsg("\"%s.%s\" is already in use as the conflict log table for this subscription",
+ nspname, relname)));
+ pfree(nspname);
+ }
+ else
+ {
+ /*
+ * Create the conflict log table after dropping any
+ * pre-existing one.
+ */
+ if (old_relname)
+ drop_conflict_log_table(old_nspid, old_relname);
+ create_conflict_log_table(nspid, relname);
+
+ values[Anum_pg_subscription_subconflictlognspid - 1] =
+ ObjectIdGetDatum(nspid);
+ values[Anum_pg_subscription_subconflictlogtable - 1] =
+ CStringGetTextDatum(relname);
+ replaces[Anum_pg_subscription_subconflictlognspid - 1] =
+ true;
+ replaces[Anum_pg_subscription_subconflictlogtable - 1] =
+ true;
+ }
+
+ if (old_relname != NULL)
+ pfree(old_relname);
+ }
+
update_tuple = true;
break;
}
@@ -2027,6 +2125,8 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
Form_pg_subscription form;
List *rstates;
bool must_use_password;
+ Oid conflictlogtable_nsp = InvalidOid;
+ char *conflictlogtable = NULL;
/*
* The launcher may concurrently start a new worker for this subscription.
@@ -2110,6 +2210,20 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
ObjectAddressSet(myself, SubscriptionRelationId, subid);
EventTriggerSQLDropAddObject(&myself, true, true);
+ /* Fetch the conflict log table information. */
+ conflictlogtable =
+ get_subscription_conflict_log_table(subid, &conflictlogtable_nsp);
+
+ /*
+ * If the subscription had a conflict log table, drop it now. This happens
+ * before deleting the subscription tuple.
+ */
+ if (conflictlogtable)
+ {
+ drop_conflict_log_table(conflictlogtable_nsp, conflictlogtable);
+ pfree(conflictlogtable);
+ }
+
/* Remove the tuple from catalog. */
CatalogTupleDelete(rel, &tup->t_self);
@@ -3188,3 +3302,122 @@ defGetStreamingMode(DefElem *def)
def->defname)));
return LOGICALREP_STREAM_OFF; /* keep compiler quiet */
}
+
+/*
+ * Create conflict log table.
+ *
+ * The subscription owner becomes the owner of this table and has all
+ * privileges on it.
+ */
+static void
+create_conflict_log_table(Oid namespaceId, char *conflictrel)
+{
+ StringInfoData querybuf;
+
+ /* Report an error if the specified conflict log table already exists. */
+ if (OidIsValid(get_relname_relid(conflictrel, namespaceId)))
+ ereport(ERROR,
+ (errcode(ERRCODE_DUPLICATE_TABLE),
+ errmsg("cannot create conflict log table \"%s.%s\" because a table with that name already exists",
+ get_namespace_name(namespaceId), conflictrel),
+ errhint("Use a different name for the conflict log table or drop the existing table.")));
+
+ initStringInfo(&querybuf);
+
+ /* build and execute the CREATE TABLE query. */
+ appendStringInfo(&querybuf,
+ "CREATE TABLE %s.%s ("
+ "relid Oid,"
+ "schemaname TEXT,"
+ "relname TEXT,"
+ "conflict_type TEXT,"
+ "local_xid xid,"
+ "remote_xid xid,"
+ "remote_commit_lsn pg_lsn,"
+ "local_commit_ts TIMESTAMPTZ,"
+ "remote_commit_ts TIMESTAMPTZ,"
+ "local_origin TEXT,"
+ "remote_origin TEXT,"
+ "key_tuple JSON,"
+ "local_tuple JSON,"
+ "remote_tuple JSON)",
+ quote_identifier(get_namespace_name(namespaceId)),
+ quote_identifier(conflictrel));
+
+ if (SPI_connect() != SPI_OK_CONNECT)
+ elog(ERROR, "SPI_connect failed");
+
+ if (SPI_execute(querybuf.data, false, 0) != SPI_OK_UTILITY)
+ elog(ERROR, "SPI_exec failed: %s", querybuf.data);
+
+ if (SPI_finish() != SPI_OK_FINISH)
+ elog(ERROR, "SPI_finish failed");
+
+ pfree(querybuf.data);
+}
+
+/*
+ * Drop the conflict log table.
+ *
+ * This function uses SPI to execute DROP TABLE IF EXISTS.
+ * We use IF EXISTS to avoid errors if the user manually dropped it first.
+ */
+static void
+drop_conflict_log_table(Oid namespaceId, char *conflictrel)
+{
+ StringInfoData querybuf;
+
+ initStringInfo(&querybuf);
+
+ /* Drop the conflict log table if it exists. */
+ appendStringInfo(&querybuf,
+ "DROP TABLE IF EXISTS %s.%s",
+ quote_identifier(get_namespace_name(namespaceId)),
+ quote_identifier(conflictrel));
+
+ if (SPI_connect() != SPI_OK_CONNECT)
+ elog(ERROR, "SPI_connect failed");
+
+ if (SPI_execute(querybuf.data, false, 0) != SPI_OK_UTILITY)
+ elog(ERROR, "SPI_exec failed: %s", querybuf.data);
+
+ if (SPI_finish() != SPI_OK_FINISH)
+ elog(ERROR, "SPI_finish failed");
+
+ pfree(querybuf.data);
+}
+
+/*
+ * Check if the specified relation is used as a conflict log table by any
+ * subscription.
+ */
+bool
+IsConflictLogRelid(Oid relid)
+{
+ Relation rel;
+ TableScanDesc scan;
+ HeapTuple tup;
+ bool is_clt = false;
+
+ rel = table_open(SubscriptionRelationId, AccessShareLock);
+ scan = table_beginscan_catalog(rel, 0, NULL);
+
+ while (HeapTupleIsValid(tup = heap_getnext(scan, ForwardScanDirection)))
+ {
+ Form_pg_subscription subform = (Form_pg_subscription) GETSTRUCT(tup);
+ Oid nspid;
+ char *relname;
+
+ relname = get_subscription_conflict_log_table(subform->oid, &nspid);
+ if (relname && relid == get_relname_relid(relname, nspid))
+ {
+ is_clt = true;
+ break;
+ }
+ }
+
+ table_endscan(scan);
+ table_close(rel, AccessShareLock);
+
+ return is_clt;
+}
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 16695592265..16c103ecdd2 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -15,13 +15,24 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/tableam.h"
+#include "access/table.h"
+#include "catalog/indexing.h"
+#include "catalog/namespace.h"
+#include "catalog/pg_namespace_d.h"
+#include "catalog/pg_type.h"
#include "executor/executor.h"
+#include "executor/spi.h"
+#include "funcapi.h"
#include "pgstat.h"
#include "replication/conflict.h"
#include "replication/worker_internal.h"
#include "storage/lmgr.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/lsyscache.h"
+#include "utils/pg_lsn.h"
static const char *const ConflictTypeNames[] = {
[CT_INSERT_EXISTS] = "insert_exists",
@@ -50,8 +61,26 @@ static char *build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *localslot,
TupleTableSlot *remoteslot,
Oid indexoid);
+static void build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull);
static char *build_index_value_desc(EState *estate, Relation localrel,
TupleTableSlot *slot, Oid indexoid);
+static Datum tuple_table_slot_to_json_datum(TupleTableSlot *slot);
+static Datum tuple_table_slot_to_ri_json_datum(EState *estate,
+ Relation localrel,
+ Oid replica_index,
+ TupleTableSlot *slot);
+static void prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ TransactionId local_xid,
+ TimestampTz local_ts,
+ ConflictType conflict_type,
+ RepOriginId origin_id,
+ TupleTableSlot *searchslot,
+ TupleTableSlot *localslot,
+ TupleTableSlot *remoteslot);
/*
* Get the xmin and commit timestamp data (origin and timestamp) associated
@@ -106,12 +135,14 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
TupleTableSlot *remoteslot, List *conflicttuples)
{
Relation localrel = relinfo->ri_RelationDesc;
+ Relation conflictlogrel = GetConflictLogTableRel();
StringInfoData err_detail;
initStringInfo(&err_detail);
/* Form errdetail message by combining conflicting tuples information. */
foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
+ {
errdetail_apply_conflict(estate, relinfo, type, searchslot,
conflicttuple->slot, remoteslot,
conflicttuple->indexoid,
@@ -120,6 +151,30 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
conflicttuple->ts,
&err_detail);
+ /* Insert conflict details to conflict log table. */
+ if (conflictlogrel)
+ {
+ /*
+ * Prepare the conflict log tuple. If the error level is below
+ * ERROR, insert it immediately. Otherwise, defer the insertion to
+ * a new transaction after the current one aborts, ensuring the
+ * insertion of the log tuple is not rolled back.
+ */
+ prepare_conflict_log_tuple(estate,
+ relinfo->ri_RelationDesc,
+ conflictlogrel,
+ conflicttuple->xmin,
+ conflicttuple->ts, type,
+ conflicttuple->origin,
+ searchslot, conflicttuple->slot,
+ remoteslot);
+ if (elevel < ERROR)
+ InsertConflictLogTuple(conflictlogrel);
+
+ table_close(conflictlogrel, AccessExclusiveLock);
+ }
+ }
+
pgstat_report_subscription_conflict(MySubscription->oid, type);
ereport(elevel,
@@ -162,6 +217,69 @@ InitConflictIndexes(ResultRelInfo *relInfo)
relInfo->ri_onConflictArbiterIndexes = uniqueIndexes;
}
+/*
+ * GetConflictLogTableRel
+ *
+ * Get the information of the specific conflict log table defined in
+ * pg_subscription and opens the relation for insertion. The caller is
+ * responsible for closing the returned relation handle.
+ */
+Relation
+GetConflictLogTableRel(void)
+{
+ Oid nspid;
+ Oid conflictlogrelid;
+ Relation conflictlogrel = NULL;
+ char *conflictlogtable;
+
+ /* If conflict log table is not set for the subscription just return. */
+ conflictlogtable = get_subscription_conflict_log_table(
+ MyLogicalRepWorker->subid, &nspid);
+ if (conflictlogtable == NULL)
+ {
+ pfree(conflictlogtable);
+ return NULL;
+ }
+
+ conflictlogrelid = get_relname_relid(conflictlogtable, nspid);
+ if (OidIsValid(conflictlogrelid))
+ conflictlogrel = table_open(conflictlogrelid, RowExclusiveLock);
+
+ /* Conflict log table is dropped or not accessible. */
+ if (conflictlogrel == NULL)
+ ereport(WARNING,
+ (errcode(ERRCODE_UNDEFINED_TABLE),
+ errmsg("conflict log table \"%s.%s\" does not exist",
+ get_namespace_name(nspid), conflictlogtable)));
+
+ pfree(conflictlogtable);
+
+ return conflictlogrel;
+}
+
+/*
+ * InsertConflictLogTuple
+ *
+ * Insert conflict log tuple into the conflict log table. It uses
+ * HEAP_INSERT_NO_LOGICAL to explicitly block logical decoding
+ * of the tuple inserted into the conflict log table.
+ */
+void
+InsertConflictLogTuple(Relation conflictlogrel)
+{
+ int options = HEAP_INSERT_NO_LOGICAL;
+
+ /* A valid tuple must be prepared and store into MyLogicalRepWorker. */
+ Assert(MyLogicalRepWorker->conflict_log_tuple != NULL);
+
+ heap_insert(conflictlogrel, MyLogicalRepWorker->conflict_log_tuple,
+ GetCurrentCommandId(true), options, NULL);
+
+ /* Free conflict log tuple. */
+ heap_freetuple(MyLogicalRepWorker->conflict_log_tuple);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+}
+
/*
* Add SQLSTATE error code to the current conflict report.
*/
@@ -472,6 +590,40 @@ build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
return tuple_value.data;
}
+/*
+ * Helper function to extract the "raw" index key Datums and their null flags
+ * from a TupleTableSlot, given an already open index descriptor.
+ * This is the reusable core logic.
+ */
+static void
+build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull)
+{
+ TupleTableSlot *tableslot = slot;
+
+ /*
+ * If the slot is a virtual slot, copy it into a heap tuple slot as
+ * FormIndexDatum only works with heap tuple slots.
+ */
+ if (TTS_IS_VIRTUAL(slot))
+ {
+ /* Slot is created within the EState's tuple table */
+ tableslot = table_slot_create(localrel, &estate->es_tupleTable);
+ tableslot = ExecCopySlot(tableslot, slot);
+ }
+
+ /*
+ * Initialize ecxt_scantuple for potential use in FormIndexDatum
+ */
+ GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+
+ /* Form the index datums */
+ FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values,
+ isnull);
+}
+
/*
* Helper functions to construct a string describing the contents of an index
* entry. See BuildIndexValueDescription for details.
@@ -487,41 +639,203 @@ build_index_value_desc(EState *estate, Relation localrel, TupleTableSlot *slot,
Relation indexDesc;
Datum values[INDEX_MAX_KEYS];
bool isnull[INDEX_MAX_KEYS];
- TupleTableSlot *tableslot = slot;
- if (!tableslot)
+ if (!slot)
return NULL;
Assert(CheckRelationOidLockedByMe(indexoid, RowExclusiveLock, true));
indexDesc = index_open(indexoid, NoLock);
- /*
- * If the slot is a virtual slot, copy it into a heap tuple slot as
- * FormIndexDatum only works with heap tuple slots.
- */
- if (TTS_IS_VIRTUAL(slot))
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+
+ index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+
+ index_close(indexDesc, NoLock);
+
+ return index_value;
+}
+
+/*
+ * tuple_table_slot_to_json_datum
+ *
+ * Helper function to convert a TupleTableSlot to Jsonb.
+ */
+static Datum
+tuple_table_slot_to_json_datum(TupleTableSlot *slot)
+{
+ HeapTuple tuple;
+ Datum datum;
+ Datum json;
+
+ Assert(slot != NULL);
+
+ tuple = ExecCopySlotHeapTuple(slot);
+ datum = heap_copy_tuple_as_datum(tuple, slot->tts_tupleDescriptor);
+
+ json = DirectFunctionCall1(row_to_json, datum);
+ heap_freetuple(tuple);
+
+ return json;
+}
+
+/*
+ * tuple_table_slot_to_ri_json_datum
+ *
+ * Fetch replica identity key from the tuple table slot and convert into a
+ * jsonb datum.
+ */
+static Datum
+tuple_table_slot_to_ri_json_datum(EState *estate, Relation localrel,
+ Oid replica_index, TupleTableSlot *slot)
+{
+ Relation indexDesc;
+ Datum values[INDEX_MAX_KEYS];
+ bool isnull[INDEX_MAX_KEYS];
+ HeapTuple tuple;
+ TupleDesc tupdesc;
+ Datum datum;
+
+ Assert(slot != NULL);
+
+ Assert(CheckRelationOidLockedByMe(replica_index, RowExclusiveLock, true));
+
+ indexDesc = index_open(replica_index, NoLock);
+
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+ tupdesc = RelationGetDescr(indexDesc);
+
+ /* Bless the tupdesc so it can be looked up by row_to_json. */
+ BlessTupleDesc(tupdesc);
+
+ /* Form the replica identity tuple. */
+ tuple = heap_form_tuple(tupdesc, values, isnull);
+ datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ index_close(indexDesc, NoLock);
+
+ /* Convert to a JSONB datum. */
+ return DirectFunctionCall1(row_to_json, datum);
+}
+
+/*
+ * prepare_conflict_log_tuple
+ *
+ * This routine prepares a tuple detailing a conflict encountered during
+ * logical replication. The prepared tuple will be stored in
+ * MyLogicalRepWorker->conflict_log_tuple which should be inserted into the
+ * conflict log table by calling InsertConflictLogTuple.
+ */
+static void
+prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ TransactionId local_xid, TimestampTz local_ts,
+ ConflictType conflict_type, RepOriginId origin_id,
+ TupleTableSlot *searchslot,
+ TupleTableSlot *localslot,
+ TupleTableSlot *remoteslot)
+{
+#define MAX_CONFLICT_ATTR_NUM 15
+ Datum values[MAX_CONFLICT_ATTR_NUM];
+ bool nulls[MAX_CONFLICT_ATTR_NUM];
+ int attno;
+ char *origin = NULL;
+ char *remote_origin = NULL;
+ HeapTuple tup;
+ MemoryContext oldctx;
+
+ Assert(MyLogicalRepWorker->conflict_log_tuple == NULL);
+
+ /* Initialize values and nulls arrays. */
+ memset(values, 0, sizeof(Datum) * MAX_CONFLICT_ATTR_NUM);
+ memset(nulls, 0, sizeof(bool) * MAX_CONFLICT_ATTR_NUM);
+
+ /* Populate the values and nulls arrays. */
+ attno = 0;
+ values[attno++] = ObjectIdGetDatum(RelationGetRelid(rel));
+
+ values[attno++] =
+ CStringGetTextDatum(get_namespace_name(RelationGetNamespace(rel)));
+
+ values[attno++] = CStringGetTextDatum(RelationGetRelationName(rel));
+
+ values[attno++] = CStringGetTextDatum(ConflictTypeNames[conflict_type]);
+
+ if (TransactionIdIsValid(local_xid))
+ values[attno++] = TransactionIdGetDatum(local_xid);
+ else
+ nulls[attno++] = true;
+
+ if (TransactionIdIsValid(remote_xid))
+ values[attno++] = TransactionIdGetDatum(remote_xid);
+ else
+ nulls[attno++] = true;
+
+ values[attno++] = LSNGetDatum(remote_final_lsn);
+
+ if (local_ts > 0)
+ values[attno++] = TimestampTzGetDatum(local_ts);
+ else
+ nulls[attno++] = true;
+
+ if (remote_commit_ts > 0)
+ values[attno++] = TimestampTzGetDatum(remote_commit_ts);
+ else
+ nulls[attno++] = true;
+
+ if (origin_id != InvalidRepOriginId)
+ replorigin_by_oid(origin_id, true, &origin);
+
+ if (origin != NULL)
+ values[attno++] = CStringGetTextDatum(origin);
+ else
+ nulls[attno++] = true;
+
+ if (replorigin_session_origin != InvalidRepOriginId)
+ replorigin_by_oid(replorigin_session_origin, true, &remote_origin);
+
+ if (remote_origin != NULL)
+ values[attno++] = CStringGetTextDatum(remote_origin);
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(searchslot))
{
- tableslot = table_slot_create(localrel, &estate->es_tupleTable);
- tableslot = ExecCopySlot(tableslot, slot);
+ Oid replica_index = GetRelationIdentityOrPK(rel);
+
+ /*
+ * If the table has a valid replica identity index, build the index
+ * json datum from key value. Otherwise, construct it from the complete
+ * tuple in REPLICA IDENTITY FULL cases.
+ */
+ if (OidIsValid(replica_index))
+ values[attno++] = tuple_table_slot_to_ri_json_datum(estate, rel,
+ replica_index,
+ searchslot);
+ else
+ values[attno++] = tuple_table_slot_to_json_datum(searchslot);
}
+ else
+ nulls[attno++] = true;
- /*
- * Initialize ecxt_scantuple for potential use in FormIndexDatum when
- * index expressions are present.
- */
- GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+ if (!TupIsNull(localslot))
+ values[attno++] = tuple_table_slot_to_json_datum(localslot);
+ else
+ nulls[attno++] = true;
- /*
- * The values/nulls arrays passed to BuildIndexValueDescription should be
- * the results of FormIndexDatum, which are the "raw" input to the index
- * AM.
- */
- FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values, isnull);
+ if (!TupIsNull(remoteslot))
+ values[attno++] = tuple_table_slot_to_json_datum(remoteslot);
+ else
+ nulls[attno++] = true;
- index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+ Assert(attno + 1 == MAX_CONFLICT_ATTR_NUM);
- index_close(indexDesc, NoLock);
+ oldctx = MemoryContextSwitchTo(ApplyContext);
+ tup = heap_form_tuple(RelationGetDescr(conflictlogrel), values, nulls);
+ MemoryContextSwitchTo(oldctx);
- return index_value;
+ /* Store conflict_log_tuple into the worker slot for inserting it later. */
+ MyLogicalRepWorker->conflict_log_tuple = tup;
}
diff --git a/src/backend/replication/logical/launcher.c b/src/backend/replication/logical/launcher.c
index fdf1ccad462..2364146ca36 100644
--- a/src/backend/replication/logical/launcher.c
+++ b/src/backend/replication/logical/launcher.c
@@ -477,6 +477,7 @@ retry:
worker->oldest_nonremovable_xid = retain_dead_tuples
? MyReplicationSlot->data.xmin
: InvalidTransactionId;
+ worker->conflict_log_tuple = NULL;
worker->last_lsn = InvalidXLogRecPtr;
TIMESTAMP_NOBEGIN(worker->last_send_time);
TIMESTAMP_NOBEGIN(worker->last_recv_time);
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 93970c6af29..fee757ee931 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -482,7 +482,9 @@ static bool MySubscriptionValid = false;
static List *on_commit_wakeup_workers_subids = NIL;
bool in_remote_transaction = false;
-static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+TransactionId remote_xid = InvalidTransactionId;
+TimestampTz remote_commit_ts = 0;
/* fields valid only when processing streamed transaction */
static bool in_streamed_transaction = false;
@@ -1219,6 +1221,8 @@ apply_handle_begin(StringInfo s)
set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
remote_final_lsn = begin_data.final_lsn;
+ remote_commit_ts = begin_data.committime;
+ remote_xid = begin_data.xid;
maybe_start_skipping_changes(begin_data.final_lsn);
@@ -1745,6 +1749,10 @@ apply_handle_stream_start(StringInfo s)
/* extract XID of the top-level transaction */
stream_xid = logicalrep_read_stream_start(s, &first_segment);
+ remote_xid = stream_xid;
+ remote_final_lsn = InvalidXLogRecPtr;
+ remote_commit_ts = 0;
+
if (!TransactionIdIsValid(stream_xid))
ereport(ERROR,
(errcode(ERRCODE_PROTOCOL_VIOLATION),
@@ -5609,6 +5617,26 @@ start_apply(XLogRecPtr origin_startpos)
pgstat_report_subscription_error(MySubscription->oid,
MyLogicalRepWorker->type);
+ /*
+ * Insert any pending conflict log tuple under a new transaction.
+ */
+ if (MyLogicalRepWorker->conflict_log_tuple != NULL)
+ {
+ Relation conflictlogrel;
+
+ StartTransactionCommand();
+ PushActiveSnapshot(GetTransactionSnapshot());
+
+ /* Open conflict log table. */
+ conflictlogrel = GetConflictLogTableRel();
+ InsertConflictLogTuple(conflictlogrel);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+ table_close(conflictlogrel, AccessExclusiveLock);
+
+ PopActiveSnapshot();
+ CommitTransactionCommand();
+ }
+
PG_RE_THROW();
}
}
diff --git a/src/backend/utils/cache/lsyscache.c b/src/backend/utils/cache/lsyscache.c
index fa7cd7e06a7..12db6676406 100644
--- a/src/backend/utils/cache/lsyscache.c
+++ b/src/backend/utils/cache/lsyscache.c
@@ -3881,3 +3881,41 @@ get_subscription_name(Oid subid, bool missing_ok)
return subname;
}
+
+/*
+ * get_subscription_conflict_log_table
+ *
+ * Get conflict log table name and namespace id from subscription.
+ */
+char *
+get_subscription_conflict_log_table(Oid subid, Oid *nspid)
+{
+ HeapTuple tup;
+ Datum datum;
+ bool isnull;
+ char *relname = NULL;
+ Form_pg_subscription subform;
+
+ *nspid = InvalidOid;
+
+ tup = SearchSysCache1(SUBSCRIPTIONOID, ObjectIdGetDatum(subid));
+
+ if (!HeapTupleIsValid(tup))
+ return NULL;
+
+ subform = (Form_pg_subscription) GETSTRUCT(tup);
+
+ /* Get conflict log table name. */
+ datum = SysCacheGetAttr(SUBSCRIPTIONOID,
+ tup,
+ Anum_pg_subscription_subconflictlogtable,
+ &isnull);
+ if (!isnull)
+ {
+ *nspid = subform->subconflictlognspid;
+ relname = pstrdup(TextDatumGetCString(datum));
+ }
+
+ ReleaseSysCache(tup);
+ return relname;
+}
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index 36f24502842..c18ec248e5d 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -6806,7 +6806,7 @@ describeSubscriptions(const char *pattern, bool verbose)
printQueryOpt myopt = pset.popt;
static const bool translate_columns[] = {false, false, false, false,
false, false, false, false, false, false, false, false, false, false,
- false, false, false, false};
+ false, false, false, false, false};
if (pset.sversion < 100000)
{
@@ -6900,6 +6900,12 @@ describeSubscriptions(const char *pattern, bool verbose)
appendPQExpBuffer(&buf,
", subskiplsn AS \"%s\"\n",
gettext_noop("Skip LSN"));
+
+ /* Conflict log table is only supported in v19 and higher */
+ if (pset.sversion >= 190000)
+ appendPQExpBuffer(&buf,
+ ", subconflictlogtable AS \"%s\"\n",
+ gettext_noop("Conflict log table"));
}
/* Only display subscriptions in current database. */
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 51806597037..4057c0a22b4 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -2344,8 +2344,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("(", "PUBLICATION");
/* ALTER SUBSCRIPTION <name> SET ( */
else if (Matches("ALTER", "SUBSCRIPTION", MatchAny, MatchAnyN, "SET", "("))
- COMPLETE_WITH("binary", "disable_on_error", "failover",
- "max_retention_duration", "origin",
+ COMPLETE_WITH("binary", "conflict_log_table", "disable_on_error",
+ "failover", "max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
"synchronous_commit", "two_phase");
@@ -3814,8 +3814,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("WITH (");
/* Complete "CREATE SUBSCRIPTION <name> ... WITH ( <opt>" */
else if (Matches("CREATE", "SUBSCRIPTION", MatchAnyN, "WITH", "("))
- COMPLETE_WITH("binary", "connect", "copy_data", "create_slot",
- "disable_on_error", "enabled", "failover",
+ COMPLETE_WITH("binary", "conflict_log_table", "connect", "copy_data",
+ "create_slot", "disable_on_error", "enabled", "failover",
"max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index 55cb9b1eefa..f4526c15ec3 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -80,6 +80,8 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
bool subretaindeadtuples; /* True if dead tuples useful for
* conflict detection are retained */
+ Oid subconflictlognspid; /* Namespace Oid in which the conflict
+ * log table is created. */
int32 submaxretention; /* The maximum duration (in milliseconds)
* for which information useful for
@@ -105,6 +107,9 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
/* Only publish data originating from the specified origin */
text suborigin BKI_DEFAULT(LOGICALREP_ORIGIN_ANY);
+
+ /* Conflict log table name if specified */
+ text subconflictlogtable;
#endif
} FormData_pg_subscription;
diff --git a/src/include/commands/subscriptioncmds.h b/src/include/commands/subscriptioncmds.h
index fb4e26a51a4..b5e9cbf8bfe 100644
--- a/src/include/commands/subscriptioncmds.h
+++ b/src/include/commands/subscriptioncmds.h
@@ -36,4 +36,6 @@ extern void CheckSubDeadTupleRetention(bool check_guc, bool sub_disabled,
bool retention_active,
bool max_retention_set);
+extern bool IsConflictLogRelid(Oid relid);
+
#endif /* SUBSCRIPTIONCMDS_H */
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index c8fbf9e51b8..ae752015281 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -9,9 +9,11 @@
#ifndef CONFLICT_H
#define CONFLICT_H
+#include "access/htup.h"
#include "access/xlogdefs.h"
#include "nodes/pg_list.h"
#include "utils/timestamp.h"
+#include "utils/relcache.h"
/* Avoid including execnodes.h here */
typedef struct EState EState;
@@ -89,4 +91,6 @@ extern void ReportApplyConflict(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *remoteslot,
List *conflicttuples);
extern void InitConflictIndexes(ResultRelInfo *relInfo);
+extern Relation GetConflictLogTableRel(void);
+extern void InsertConflictLogTuple(Relation conflictlogrel);
#endif
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index f081619f151..0d07f5efe47 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -101,6 +101,9 @@ typedef struct LogicalRepWorker
*/
TransactionId oldest_nonremovable_xid;
+ /* A conflict log tuple which is prepared but not yet inserted. */
+ HeapTuple conflict_log_tuple;
+
/* Stats. */
XLogRecPtr last_lsn;
TimestampTz last_send_time;
@@ -256,6 +259,10 @@ extern PGDLLIMPORT bool InitializingApplyWorker;
extern PGDLLIMPORT List *table_states_not_ready;
+extern XLogRecPtr remote_final_lsn;
+extern TimestampTz remote_commit_ts;
+extern TransactionId remote_xid;
+
extern void logicalrep_worker_attach(int slot);
extern LogicalRepWorker *logicalrep_worker_find(LogicalRepWorkerType wtype,
Oid subid, Oid relid,
diff --git a/src/include/utils/lsyscache.h b/src/include/utils/lsyscache.h
index 50fb149e9ac..3bebf04bf51 100644
--- a/src/include/utils/lsyscache.h
+++ b/src/include/utils/lsyscache.h
@@ -210,6 +210,7 @@ extern Oid get_publication_oid(const char *pubname, bool missing_ok);
extern char *get_publication_name(Oid pubid, bool missing_ok);
extern Oid get_subscription_oid(const char *subname, bool missing_ok);
extern char *get_subscription_name(Oid subid, bool missing_ok);
+extern char *get_subscription_conflict_log_table(Oid subid, Oid *nspid);
#define type_is_array(typid) (get_element_type(typid) != InvalidOid)
/* type_is_array_domain accepts both plain arrays and domains over arrays */
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index 327d1e7731f..f71ff58424e 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -116,18 +116,18 @@ CREATE SUBSCRIPTION regress_testsub4 CONNECTION 'dbname=regress_doesnotexist' PU
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub4 SET (origin = any);
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
DROP SUBSCRIPTION regress_testsub3;
@@ -145,10 +145,10 @@ ALTER SUBSCRIPTION regress_testsub CONNECTION 'foobar';
ERROR: invalid connection string syntax: missing "=" after "foobar" in connection info string
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET PUBLICATION testpub2, testpub3 WITH (refresh = false);
@@ -157,10 +157,10 @@ ALTER SUBSCRIPTION regress_testsub SET (slot_name = 'newname');
ALTER SUBSCRIPTION regress_testsub SET (password_required = false);
ALTER SUBSCRIPTION regress_testsub SET (run_as_owner = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (password_required = true);
@@ -176,10 +176,10 @@ ERROR: unrecognized subscription parameter: "create_slot"
-- ok
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/12345');
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345 |
(1 row)
-- ok - with lsn = NONE
@@ -188,10 +188,10 @@ ALTER SUBSCRIPTION regress_testsub SKIP (lsn = NONE);
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/0');
ERROR: invalid WAL location (LSN): 0/0
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 |
(1 row)
BEGIN;
@@ -223,10 +223,10 @@ ALTER SUBSCRIPTION regress_testsub_foo SET (synchronous_commit = foobar);
ERROR: invalid value for parameter "synchronous_commit": "foobar"
HINT: Available values: local, remote_write, remote_apply, on, off.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
----------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+---------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000 |
(1 row)
-- rename back to keep the rest simple
@@ -255,19 +255,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (binary = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -279,27 +279,27 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = parallel);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- fail - publication already exists
@@ -314,10 +314,10 @@ ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refr
ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refresh = false);
ERROR: publication "testpub1" is already in subscription "regress_testsub"
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- fail - publication used more than once
@@ -332,10 +332,10 @@ ERROR: publication "testpub3" is not in subscription "regress_testsub"
-- ok - delete publications
ALTER SUBSCRIPTION regress_testsub DROP PUBLICATION testpub1, testpub2 WITH (refresh = false);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -371,19 +371,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- we can alter streaming when two_phase enabled
ALTER SUBSCRIPTION regress_testsub SET (streaming = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -393,10 +393,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -409,18 +409,18 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (disable_on_error = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -433,10 +433,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -450,19 +450,19 @@ NOTICE: max_retention_duration is ineffective when retain_dead_tuples is disabl
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- ok
ALTER SUBSCRIPTION regress_testsub SET (max_retention_duration = 0);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -517,7 +517,150 @@ COMMIT;
-- ok, owning it is enough for this stuff
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG TABLE TESTS
+--
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+-- fail - conflict_log_table specified when table already exists
+CREATE TABLE public.regress_conflict_log_temp (id int);
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log_temp');
+ERROR: cannot create conflict log table "public.regress_conflict_log_temp" because a table with that name already exists
+HINT: Use a different name for the conflict log table or drop the existing table.
+DROP TABLE public.regress_conflict_log_temp;
+-- ok - conflict_log_table creation with CREATE SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- check metadata in pg_subscription
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname | subconflictlogtable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test1 | regress_conflict_log1 | t
+(1 row)
+
+-- check if the table exists and has the correct schema (15 columns)
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attnum > 0;
+ count
+-------
+ 14
+(1 row)
+
+-- check a specific column type (e.g., key_tuple should be JSON)
+SELECT format_type(atttypid, atttypmod) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attname = 'key_tuple';
+ format_type
+-------------
+ json
+(1 row)
+
+\dRs+
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+-----------------------
+ regress_conflict_test1 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | regress_conflict_log1
+(1 row)
+
+-- ok - adding conflict_log_table with ALTER SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log2');
+-- check metadata after ALTER
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogtable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test2 | regress_conflict_log2 | t
+(1 row)
+
+-- ok - change the conflict log table name for an existing subscription that already had one
+CREATE SCHEMA clt;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'clt.regress_conflict_log3');
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogtable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test2 | regress_conflict_log3 | f
+(1 row)
+
+\dRs+
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+-----------------------
+ regress_conflict_test1 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | regress_conflict_log1
+ regress_conflict_test2 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | regress_conflict_log3
+(2 rows)
+
+-- check the new table was created and the old table was dropped
+SELECT count(*) FROM pg_class WHERE relname = 'regress_conflict_log2';
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'clt.regress_conflict_log3'::regclass AND attnum > 0;
+ count
+-------
+ 14
+(1 row)
+
+-- ok (NOTICE) - set conflict_log_table to one already used by this subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'clt.regress_conflict_log3');
+NOTICE: "clt.regress_conflict_log3" is already in use as the conflict log table for this subscription
+-- fail - try to publish the conflict_log_table
+CREATE PUBLICATION pub FOR TABLE clt.regress_conflict_log3;
+ERROR: cannot add relation "regress_conflict_log3" to publication
+DETAIL: This operation is not supported for conflict log tables.
+-- suppress warning that depends on wal_level
+SET client_min_messages = 'ERROR';
+-- ok - conflict_log_table should not be published with ALL TABLE
+CREATE PUBLICATION pub FOR TABLES IN SCHEMA clt;
+SELECT * FROM pg_publication_tables WHERE pubname = 'pub';
+ pubname | schemaname | tablename | attnames | rowfilter
+---------+------------+-----------+----------+-----------
+(0 rows)
+
+\dt+ clt.regress_conflict_log3
+ List of tables
+ Schema | Name | Type | Owner | Persistence | Size | Description
+--------+-----------------------+-------+---------------------------+-------------+------------+-------------
+ clt | regress_conflict_log3 | table | regress_subscription_user | permanent | 8192 bytes |
+(1 row)
+
+DROP PUBLICATION pub;
+-- fail - set conflict_log_table to one already used by a different subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log1');
+ERROR: cannot create conflict log table "public.regress_conflict_log1" because a table with that name already exists
+HINT: Use a different name for the conflict log table or drop the existing table.
+-- ok - dropping subscription also drops the log table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the table was dropped
+SELECT to_regclass('public.regress_conflict_log1');
+ to_regclass
+-------------
+
+(1 row)
+
+-- ok - dropping subscription when the log table was manually dropped first
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+DROP TABLE public.regress_conflict_log1;
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the subscription was dropped successfully
+SELECT subname FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname
+---------
+(0 rows)
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
RESET SESSION AUTHORIZATION;
+DROP SCHEMA clt;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
DROP ROLE regress_subscription_user3;
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index ef0c298d2df..49bfb683c57 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -365,7 +365,94 @@ COMMIT;
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG TABLE TESTS
+--
+
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+
+-- fail - conflict_log_table specified when table already exists
+CREATE TABLE public.regress_conflict_log_temp (id int);
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log_temp');
+DROP TABLE public.regress_conflict_log_temp;
+
+-- ok - conflict_log_table creation with CREATE SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+
+-- check metadata in pg_subscription
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- check if the table exists and has the correct schema (15 columns)
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attnum > 0;
+
+-- check a specific column type (e.g., key_tuple should be JSON)
+SELECT format_type(atttypid, atttypmod) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attname = 'key_tuple';
+
+\dRs+
+
+-- ok - adding conflict_log_table with ALTER SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log2');
+
+-- check metadata after ALTER
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- ok - change the conflict log table name for an existing subscription that already had one
+CREATE SCHEMA clt;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'clt.regress_conflict_log3');
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+\dRs+
+
+-- check the new table was created and the old table was dropped
+SELECT count(*) FROM pg_class WHERE relname = 'regress_conflict_log2';
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'clt.regress_conflict_log3'::regclass AND attnum > 0;
+
+-- ok (NOTICE) - set conflict_log_table to one already used by this subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'clt.regress_conflict_log3');
+
+-- fail - try to publish the conflict_log_table
+CREATE PUBLICATION pub FOR TABLE clt.regress_conflict_log3;
+
+-- suppress warning that depends on wal_level
+SET client_min_messages = 'ERROR';
+
+-- ok - conflict_log_table should not be published with ALL TABLE
+CREATE PUBLICATION pub FOR TABLES IN SCHEMA clt;
+SELECT * FROM pg_publication_tables WHERE pubname = 'pub';
+\dt+ clt.regress_conflict_log3
+DROP PUBLICATION pub;
+
+-- fail - set conflict_log_table to one already used by a different subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log1');
+
+-- ok - dropping subscription also drops the log table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the table was dropped
+SELECT to_regclass('public.regress_conflict_log1');
+
+-- ok - dropping subscription when the log table was manually dropped first
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+DROP TABLE public.regress_conflict_log1;
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the subscription was dropped successfully
+SELECT subname FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+
RESET SESSION AUTHORIZATION;
+DROP SCHEMA clt;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
DROP ROLE regress_subscription_user3;
--
2.49.0
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-11-28 00:35 Peter Smith <smithpb2250@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
2 siblings, 1 reply; 912+ messages in thread
From: Peter Smith @ 2025-11-28 00:35 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Hi Dilip.
Some review comments for v8-0001.
======
Commit message
1.
When the patches 0001 and 0002 got merged, I think the commit message
should have been updated also to say something along the lines of:
When ALL TABLES or ALL TABLES IN SCHEMA is used with publication won't
publish the clt.
======
src/backend/catalog/pg_publication.c
check_publication_add_relation:
2.
+ /* Can't be conflict log table */
+ if (IsConflictLogRelid(RelationGetRelid(targetrel)))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot add relation \"%s\" to publication",
+ RelationGetRelationName(targetrel)),
+ errdetail("This operation is not supported for conflict log tables.")));
Should it also show the schema name of the clt in the message?
======
src/backend/commands/subscriptioncmds.c
3.
+/*
+ * Check if the specified relation is used as a conflict log table by any
+ * subscription.
+ */
+bool
+IsConflictLogRelid(Oid relid)
Most places refer to the clt. Wondering if this function ought to be
called 'IsConflictLogTable'.
======
src/backend/replication/logical/conflict.c
InsertConflictLogTuple:
4.
+ /* A valid tuple must be prepared and store into MyLogicalRepWorker. */
typo: /store into/stored in/
~~~
prepare_conflict_log_tuple:
5.
- index_close(indexDesc, NoLock);
+ oldctx = MemoryContextSwitchTo(ApplyContext);
+ tup = heap_form_tuple(RelationGetDescr(conflictlogrel), values, nulls);
+ MemoryContextSwitchTo(oldctx);
- return index_value;
+ /* Store conflict_log_tuple into the worker slot for inserting it later. */
+ MyLogicalRepWorker->conflict_log_tuple = tup;
5a.
I don't think you need the 'tup' variable. Just assign directly to
MyLogicalRepWorker->conflict_log_tuple.
~
5b.
"worker slot" -- I don't think this is a "slot".
======
src/backend/replication/logical/worker.c
6.
+ /* Open conflict log table. */
+ conflictlogrel = GetConflictLogTableRel();
+ InsertConflictLogTuple(conflictlogrel);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+ table_close(conflictlogrel, AccessExclusiveLock);
Maybe that comment should say:
/* Open conflict log table and write the tuple. */
======
src/include/replication/conflict.h
7.
+ /* A conflict log tuple which is prepared but not yet inserted. */
+ HeapTuple conflict_log_tuple;
+
typo: /which/that/ (sorry, this one is my bad from a previous review comment)
======
src/test/regress/expected/subscription.out
8.
+-- ok - change the conflict log table name for an existing
subscription that already had one
+CREATE SCHEMA clt;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table =
'clt.regress_conflict_log3');
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT
oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogtable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test2 | regress_conflict_log3 | f
+(1 row)
+
+\dRs+
+
List of subscriptions
+ Name | Owner | Enabled |
Publication | Binary | Streaming | Two-phase commit | Disable on error
| Origin | Password required | Run as owner? | Failover | Retain dead
tuples | Max retention duration | Retention active | Synchronous
commit | Conninfo | Skip LSN | Conflict log
table
+------------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+-----------------------
+ regress_conflict_test1 | regress_subscription_user | f |
{testpub} | f | parallel | d | f
| any | t | f | f | f
| 0 | f | off
| dbname=regress_doesnotexist | 0/00000000 | regress_conflict_log1
+ regress_conflict_test2 | regress_subscription_user | f |
{testpub} | f | parallel | d | f
| any | t | f | f | f
| 0 | f | off
| dbname=regress_doesnotexist | 0/00000000 | regress_conflict_log3
+(2 rows)
~
After going to the trouble of specifying the CLT on a different
schema, that information is lost by the \dRs+. How about also showing
the CLT schema name (at least when it is not "public") in the \dRs+
output.
~~~
9.
+-- ok - conflict_log_table should not be published with ALL TABLE
+CREATE PUBLICATION pub FOR TABLES IN SCHEMA clt;
+SELECT * FROM pg_publication_tables WHERE pubname = 'pub';
+ pubname | schemaname | tablename | attnames | rowfilter
+---------+------------+-----------+----------+-----------
+(0 rows)
Perhaps you should repeat this same test but using FOR ALL TABLES,
instead of only FOR TABLES IN SCHEMA
======
src/test/regress/sql/subscription.sql
10.
In one of the tests, you could call the function
pg_relation_is_publishable(clt) to verify that it returns false.
======
Kind Regards,
Peter Smith.
Fujitsu Australia
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-11-28 06:54 vignesh C <vignesh21@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
2 siblings, 1 reply; 912+ messages in thread
From: vignesh C @ 2025-11-28 06:54 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Peter Smith <smithpb2250@gmail.com>; shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, 27 Nov 2025 at 17:50, Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Thu, Nov 27, 2025 at 6:30 AM Peter Smith <smithpb2250@gmail.com> wrote:
>
> I have fixed all these comments and also the comments of 0002, now I
> feel we can actually merge 0001 and 0002, so I have merged both of
> them.
I just started to have a look at the patch, while using I found lock
level used is not correct:
I felt the reason is that table is opened with RowExclusiveLock but
closed in AccessExclusiveLock:
+ /* If conflict log table is not set for the subscription just return. */
+ conflictlogtable = get_subscription_conflict_log_table(
+
MyLogicalRepWorker->subid, &nspid);
+ if (conflictlogtable == NULL)
+ {
+ pfree(conflictlogtable);
+ return NULL;
+ }
+
+ conflictlogrelid = get_relname_relid(conflictlogtable, nspid);
+ if (OidIsValid(conflictlogrelid))
+ conflictlogrel = table_open(conflictlogrelid, RowExclusiveLock);
....
+ if (elevel < ERROR)
+ InsertConflictLogTuple(conflictlogrel);
+
+ table_close(conflictlogrel, AccessExclusiveLock);
....
2025-11-28 12:17:55.631 IST [504133] WARNING: you don't own a lock of
type AccessExclusiveLock
2025-11-28 12:17:55.631 IST [504133] CONTEXT: processing remote data
for replication origin "pg_16402" during message type "INSERT" for
replication target relation "public.t1" in transaction 761, finished
at 0/01789AB8
2025-11-28 12:17:58.033 IST [504133] WARNING: you don't own a lock of
type AccessExclusiveLock
2025-11-28 12:17:58.033 IST [504133] ERROR: conflict detected on
relation "public.t1": conflict=insert_exists
2025-11-28 12:17:58.033 IST [504133] DETAIL: Key already exists in
unique index "t1_pkey", modified in transaction 766.
Key (c1)=(1); existing local row (1, 1); remote row (1, 1).
2025-11-28 12:17:58.033 IST [504133] CONTEXT: processing remote data
for replication origin "pg_16402" during message type "INSERT" for
replication target relation "public.t1" in transaction 761, finished
at 0/01789AB8
Regards,
Vignesh
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-11-28 09:01 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
2 siblings, 2 replies; 912+ messages in thread
From: shveta malik @ 2025-11-28 09:01 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Peter Smith <smithpb2250@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Thu, Nov 27, 2025 at 5:50 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
>
> I have fixed all these comments and also the comments of 0002, now I
> feel we can actually merge 0001 and 0002, so I have merged both of
> them.
>
> Now pending work status
> 1) fixed review comments of 0003
> 2) Run pgindent -- planning to do it after we complete the first level
> of review
> 3) Subscription TAP test for logging the actual conflicts
>
Thanks for the patch. A few observations:
1)
It seems, as per LOG, 'key' and 'replica-identity' are different when
it comes to insert_exists, update_exists and
multiple_unique_conflicts, while I believe in CLT, key is
replica-identity i.e. there are no 2 separate terms. Please see below:
a)
Update_Exists:
2025-11-28 14:08:56.179 IST [60383] ERROR: conflict detected on
relation "public.tab1": conflict=update_exists
2025-11-28 14:08:56.179 IST [60383] DETAIL: Key already exists in
unique index "tab1_pkey", modified locally in transaction 790 at
2025-11-28 14:07:17.578887+05:30.
Key (i)=(40); existing local row (40, 10); remote row (40, 200);
replica identity (i)=(20).
postgres=# select conflict_type, key_tuple,local_tuple,remote_tuple
from clt where conflict_type='update_exists';
conflict_type | key_tuple | local_tuple | remote_tuple
---------------+-----------+-----------------+------------------
update_exists | {"i":20} | {"i":40,"j":10} | {"i":40,"j":200}
b)
insert_Exists:
ERROR: conflict detected on relation "public.tab1": conflict=insert_exists
DETAIL: Key already exists in unique index "tab1_pkey", modified
locally in transaction 767 at 2025-11-28 13:59:22.431097+05:30.
Key (i)=(30); existing local row (30, 10); remote row (30, 10).
postgres=# select conflict_type, key_tuple,local_tuple,remote_tuple from clt;
conflict_type | key_tuple | local_tuple | remote_tuple
----------------+-----------+-----------------+-----------------
insert_exists | | {"i":30,"j":10} | {"i":30,"j":10}
case a) has key_tuple same as replica-identity of LOG
case b) does not have replica-identity and thus key_tuple is NULL.
Does that mean we need to maintain both key_tuple and RI separately in
CLT? Thoughts?
2)
For multiple_unique_conflict (testcase is same as I shared earlier),
it asserts here:
CONTEXT: processing remote data for replication origin "pg_16390"
during message type "INSERT" for replication target relation
"public.conf_tab" in transaction 778, finished at 0/017E6DE8
TRAP: failed Assert("MyLogicalRepWorker->conflict_log_tuple == NULL"),
File: "conflict.c", Line: 749, PID: 60627
I have not checked it, but maybe
'MyLogicalRepWorker->conflict_log_tuple' is left over from the
previous few tests I tried?
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-11-28 12:20 Amit Kapila <amit.kapila16@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Amit Kapila @ 2025-11-28 12:20 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Dilip Kumar <dilipbalaut@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, Nov 18, 2025 at 3:40 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> On Thu, Nov 13, 2025 at 9:17 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Thu, Nov 13, 2025 at 2:39 PM shveta malik <shveta.malik@gmail.com> wrote:
> > >
> > > 3)
> > > We also need to think how we are going to display the info in case of
> > > multiple_unique_conflicts as there could be multiple local and remote
> > > tuples conflicting for one single operation. Example:
> > >
> > > create table conf_tab (a int primary key, b int unique, c int unique);
> > >
> > > sub: insert into conf_tab values (2,2,2), (3,3,3), (4,4,4);
> > >
> > > pub: insert into conf_tab values (2,3,4);
> > >
> > > ERROR: conflict detected on relation "public.conf_tab":
> > > conflict=multiple_unique_conflicts
> > > DETAIL: Key already exists in unique index "conf_tab_pkey", modified
> > > locally in transaction 874 at 2025-11-12 14:35:13.452143+05:30.
> > > Key (a)=(2); existing local row (2, 2, 2); remote row (2, 3, 4).
> > > Key already exists in unique index "conf_tab_b_key", modified locally
> > > in transaction 874 at 2025-11-12 14:35:13.452143+05:30.
> > > Key (b)=(3); existing local row (3, 3, 3); remote row (2, 3, 4).
> > > Key already exists in unique index "conf_tab_c_key", modified locally
> > > in transaction 874 at 2025-11-12 14:35:13.452143+05:30.
> > > Key (c)=(4); existing local row (4, 4, 4); remote row (2, 3, 4).
> > > CONTEXT: processing remote data for replication origin "pg_16392"
> > > during message type "INSERT" for replication target relation
> > > "public.conf_tab" in transaction 781, finished at 0/017FDDA0
> > >
> > > Currently in clt, we have singular terms such as 'key_tuple',
> > > 'local_tuple', 'remote_tuple'. Shall we have multiple rows inserted?
> > > But it does not look reasonable to have multiple rows inserted for a
> > > single conflict raised. I will think more about this.
> >
> > Currently I am inserting multiple records in the conflict history
> > table, the same as each tuple is logged, but couldn't find any better
> > way for this.
> >
The biggest drawback of this approach is data bloat. The incoming data
row will be stored multiple times.
> > Another option is to use an array of tuples instead of a
> > single tuple but not sure this might make things more complicated to
> > process by any external tool.
>
> It’s arguable and hard to say what the correct behaviour should be.
> I’m slightly leaning toward having a single row per conflict.
>
Yeah, it is better to either have a single row per conflict or have
two tables conflict_history and conflict_history_details to avoid data
bloat as pointed above. For example, two-table approach could be:
1. The Header Table (Incoming Data)
This stores the data that tried to be applied.
SQL
CREATE TABLE conflict_header (
conflict_id SERIAL PRIMARY KEY,
source_tx_id VARCHAR(100), -- Transaction ID from source
table_name VARCHAR(100),
operation CHAR(1), -- 'I' for Insert
incoming_data JSONB, -- Store the incoming row as JSON
...
);
2. The Detail Table (Existing Conflicting Data)
This stores the actual rows currently in the database that caused the
violations.
CREATE TABLE conflict_details (
detail_id SERIAL PRIMARY KEY,
conflict_id INT REFERENCES conflict_header(conflict_id),
constraint_name/key_tuple VARCHAR(100),
conflicting_row_data JSONB -- The existing row in the DB
that blocked the insert
);
Please don't consider these exact columns; you can use something on
the lines of what is proposed in the patch. This is just to show how
the conflict data can be rearranged. Now, one argument against this is
that users need to use JOIN to query data but still better than
bloating the table. The idea to store in a single table could be
changed to have columns like violated_constraints TEXT[], --
e.g., ['uk_email', 'uk_phone'], error_details JSONB -- e.g.,
[{"const": "uk_email", "val": "a@b.com"}, ...]. If we want to store
multiple conflicting tuples in a single column, we need to ensure it
is queryable via a JSONB column. The point in favour of a single JSONB
column to combine multiple conflicting tuples is that we need this
combination only for one kind of conflict.
Both the approaches have their pros and cons. I feel we should dig a
bit deeper for both by laying out details for each method and see what
others think.
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-01 04:41 Dilip Kumar <dilipbalaut@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-12-01 04:41 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Fri, Nov 28, 2025 at 5:50 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Tue, Nov 18, 2025 at 3:40 PM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > On Thu, Nov 13, 2025 at 9:17 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > On Thu, Nov 13, 2025 at 2:39 PM shveta malik <shveta.malik@gmail.com> wrote:
> > > >
> > > > 3)
> > > > We also need to think how we are going to display the info in case of
> > > > multiple_unique_conflicts as there could be multiple local and remote
> > > > tuples conflicting for one single operation. Example:
> > > >
> > > > create table conf_tab (a int primary key, b int unique, c int unique);
> > > >
> > > > sub: insert into conf_tab values (2,2,2), (3,3,3), (4,4,4);
> > > >
> > > > pub: insert into conf_tab values (2,3,4);
> > > >
> > > > ERROR: conflict detected on relation "public.conf_tab":
> > > > conflict=multiple_unique_conflicts
> > > > DETAIL: Key already exists in unique index "conf_tab_pkey", modified
> > > > locally in transaction 874 at 2025-11-12 14:35:13.452143+05:30.
> > > > Key (a)=(2); existing local row (2, 2, 2); remote row (2, 3, 4).
> > > > Key already exists in unique index "conf_tab_b_key", modified locally
> > > > in transaction 874 at 2025-11-12 14:35:13.452143+05:30.
> > > > Key (b)=(3); existing local row (3, 3, 3); remote row (2, 3, 4).
> > > > Key already exists in unique index "conf_tab_c_key", modified locally
> > > > in transaction 874 at 2025-11-12 14:35:13.452143+05:30.
> > > > Key (c)=(4); existing local row (4, 4, 4); remote row (2, 3, 4).
> > > > CONTEXT: processing remote data for replication origin "pg_16392"
> > > > during message type "INSERT" for replication target relation
> > > > "public.conf_tab" in transaction 781, finished at 0/017FDDA0
> > > >
> > > > Currently in clt, we have singular terms such as 'key_tuple',
> > > > 'local_tuple', 'remote_tuple'. Shall we have multiple rows inserted?
> > > > But it does not look reasonable to have multiple rows inserted for a
> > > > single conflict raised. I will think more about this.
> > >
> > > Currently I am inserting multiple records in the conflict history
> > > table, the same as each tuple is logged, but couldn't find any better
> > > way for this.
> > >
>
> The biggest drawback of this approach is data bloat. The incoming data
> row will be stored multiple times.
>
> > > Another option is to use an array of tuples instead of a
> > > single tuple but not sure this might make things more complicated to
> > > process by any external tool.
> >
> > It’s arguable and hard to say what the correct behaviour should be.
> > I’m slightly leaning toward having a single row per conflict.
> >
>
> Yeah, it is better to either have a single row per conflict or have
> two tables conflict_history and conflict_history_details to avoid data
> bloat as pointed above. For example, two-table approach could be:
>
> 1. The Header Table (Incoming Data)
> This stores the data that tried to be applied.
> SQL
> CREATE TABLE conflict_header (
> conflict_id SERIAL PRIMARY KEY,
> source_tx_id VARCHAR(100), -- Transaction ID from source
> table_name VARCHAR(100),
> operation CHAR(1), -- 'I' for Insert
> incoming_data JSONB, -- Store the incoming row as JSON
> ...
> );
>
> 2. The Detail Table (Existing Conflicting Data)
> This stores the actual rows currently in the database that caused the
> violations.
> CREATE TABLE conflict_details (
> detail_id SERIAL PRIMARY KEY,
> conflict_id INT REFERENCES conflict_header(conflict_id),
> constraint_name/key_tuple VARCHAR(100),
> conflicting_row_data JSONB -- The existing row in the DB
> that blocked the insert
> );
>
> Please don't consider these exact columns; you can use something on
> the lines of what is proposed in the patch. This is just to show how
> the conflict data can be rearranged. Now, one argument against this is
> that users need to use JOIN to query data but still better than
> bloating the table. The idea to store in a single table could be
> changed to have columns like violated_constraints TEXT[], --
> e.g., ['uk_email', 'uk_phone'], error_details JSONB -- e.g.,
> [{"const": "uk_email", "val": "a@b.com"}, ...]. If we want to store
> multiple conflicting tuples in a single column, we need to ensure it
> is queryable via a JSONB column. The point in favour of a single JSONB
> column to combine multiple conflicting tuples is that we need this
> combination only for one kind of conflict.
>
> Both the approaches have their pros and cons. I feel we should dig a
> bit deeper for both by laying out details for each method and see what
> others think.
The specific scenario we are discussing is when a single row from the
publisher attempts to apply an operation that causes a conflict across
multiple unique keys, with each of those unique key violations
conflicting with a different local row on the subscriber, is very
rare. IMHO this low-frequency scenario does not justify
overcomplicating the design with an array field or a multi-level
table.
Consider the infrequency of the root causes:
- How often does a table have more than 3 to 4 unique keys?
- How frequently would each of these keys conflict with a unique row
on the subscriber side?
If resolving this occasional, synthetic conflict requires inserting
two or three rows instead of a single one, this is an acceptable
trade-off considering how rare it can occur. Anyway this is my
opinion and I am open to opinions from others.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-01 08:18 Dilip Kumar <dilipbalaut@gmail.com>
parent: vignesh C <vignesh21@gmail.com>
0 siblings, 0 replies; 912+ messages in thread
From: Dilip Kumar @ 2025-12-01 08:18 UTC (permalink / raw)
To: vignesh C <vignesh21@gmail.com>; +Cc: Peter Smith <smithpb2250@gmail.com>; shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Fri, Nov 28, 2025 at 12:24 PM vignesh C <vignesh21@gmail.com> wrote:
>
> On Thu, 27 Nov 2025 at 17:50, Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Thu, Nov 27, 2025 at 6:30 AM Peter Smith <smithpb2250@gmail.com> wrote:
> >
> > I have fixed all these comments and also the comments of 0002, now I
> > feel we can actually merge 0001 and 0002, so I have merged both of
> > them.
>
> I just started to have a look at the patch, while using I found lock
> level used is not correct:
> I felt the reason is that table is opened with RowExclusiveLock but
> closed in AccessExclusiveLock:
>
> + /* If conflict log table is not set for the subscription just return. */
> + conflictlogtable = get_subscription_conflict_log_table(
> +
> MyLogicalRepWorker->subid, &nspid);
> + if (conflictlogtable == NULL)
> + {
> + pfree(conflictlogtable);
> + return NULL;
> + }
> +
> + conflictlogrelid = get_relname_relid(conflictlogtable, nspid);
> + if (OidIsValid(conflictlogrelid))
> + conflictlogrel = table_open(conflictlogrelid, RowExclusiveLock);
>
> ....
> + if (elevel < ERROR)
> + InsertConflictLogTuple(conflictlogrel);
> +
> + table_close(conflictlogrel, AccessExclusiveLock);
> ....
>
> 2025-11-28 12:17:55.631 IST [504133] WARNING: you don't own a lock of
> type AccessExclusiveLock
> 2025-11-28 12:17:55.631 IST [504133] CONTEXT: processing remote data
> for replication origin "pg_16402" during message type "INSERT" for
> replication target relation "public.t1" in transaction 761, finished
> at 0/01789AB8
> 2025-11-28 12:17:58.033 IST [504133] WARNING: you don't own a lock of
> type AccessExclusiveLock
> 2025-11-28 12:17:58.033 IST [504133] ERROR: conflict detected on
> relation "public.t1": conflict=insert_exists
> 2025-11-28 12:17:58.033 IST [504133] DETAIL: Key already exists in
> unique index "t1_pkey", modified in transaction 766.
> Key (c1)=(1); existing local row (1, 1); remote row (1, 1).
> 2025-11-28 12:17:58.033 IST [504133] CONTEXT: processing remote data
> for replication origin "pg_16402" during message type "INSERT" for
> replication target relation "public.t1" in transaction 761, finished
> at 0/01789AB8
Thanks, I will fix this.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-01 08:23 Dilip Kumar <dilipbalaut@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
1 sibling, 0 replies; 912+ messages in thread
From: Dilip Kumar @ 2025-12-01 08:23 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Peter Smith <smithpb2250@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Fri, Nov 28, 2025 at 2:32 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> On Thu, Nov 27, 2025 at 5:50 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> >
> > I have fixed all these comments and also the comments of 0002, now I
> > feel we can actually merge 0001 and 0002, so I have merged both of
> > them.
> >
> > Now pending work status
> > 1) fixed review comments of 0003
> > 2) Run pgindent -- planning to do it after we complete the first level
> > of review
> > 3) Subscription TAP test for logging the actual conflicts
> >
>
> Thanks for the patch. A few observations:
>
> 1)
> It seems, as per LOG, 'key' and 'replica-identity' are different when
> it comes to insert_exists, update_exists and
> multiple_unique_conflicts, while I believe in CLT, key is
> replica-identity i.e. there are no 2 separate terms. Please see below:
>
> a)
> Update_Exists:
> 2025-11-28 14:08:56.179 IST [60383] ERROR: conflict detected on
> relation "public.tab1": conflict=update_exists
> 2025-11-28 14:08:56.179 IST [60383] DETAIL: Key already exists in
> unique index "tab1_pkey", modified locally in transaction 790 at
> 2025-11-28 14:07:17.578887+05:30.
> Key (i)=(40); existing local row (40, 10); remote row (40, 200);
> replica identity (i)=(20).
>
> postgres=# select conflict_type, key_tuple,local_tuple,remote_tuple
> from clt where conflict_type='update_exists';
> conflict_type | key_tuple | local_tuple | remote_tuple
> ---------------+-----------+-----------------+------------------
> update_exists | {"i":20} | {"i":40,"j":10} | {"i":40,"j":200}
>
> b)
> insert_Exists:
> ERROR: conflict detected on relation "public.tab1": conflict=insert_exists
> DETAIL: Key already exists in unique index "tab1_pkey", modified
> locally in transaction 767 at 2025-11-28 13:59:22.431097+05:30.
> Key (i)=(30); existing local row (30, 10); remote row (30, 10).
>
> postgres=# select conflict_type, key_tuple,local_tuple,remote_tuple from clt;
> conflict_type | key_tuple | local_tuple | remote_tuple
> ----------------+-----------+-----------------+-----------------
> insert_exists | | {"i":30,"j":10} | {"i":30,"j":10}
>
> case a) has key_tuple same as replica-identity of LOG
> case b) does not have replica-identity and thus key_tuple is NULL.
>
> Does that mean we need to maintain both key_tuple and RI separately in
> CLT? Thoughts?
Maybe we should then have a place for both key_tuple as well as
replica identity as we are logging, what others think about this case?
> 2)
> For multiple_unique_conflict (testcase is same as I shared earlier),
> it asserts here:
> CONTEXT: processing remote data for replication origin "pg_16390"
> during message type "INSERT" for replication target relation
> "public.conf_tab" in transaction 778, finished at 0/017E6DE8
> TRAP: failed Assert("MyLogicalRepWorker->conflict_log_tuple == NULL"),
> File: "conflict.c", Line: 749, PID: 60627
>
> I have not checked it, but maybe
> 'MyLogicalRepWorker->conflict_log_tuple' is left over from the
> previous few tests I tried?
Yeah, prepare_conflict_log_tuple() is called in loop and when there
are multiple tuple we need to collect all of the tuple before
inserting it at worker exit so the current code has a bug, I will see
how we can fix it, I think this also depends upon the other discussion
we are having related to how to insert multiple unique conflict.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-01 08:27 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
2 siblings, 1 reply; 912+ messages in thread
From: shveta malik @ 2025-12-01 08:27 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Thu, Nov 13, 2025 at 9:17 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Thu, Nov 13, 2025 at 2:39 PM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > > Few observations related to publication.
> > > ------------------------------
>
> Thanks Shveta, for testing and sharing your thoughts. IMHO for
> conflict log tables it should be good enough if we restrict it when
> ALL TABLE options are used, I don't think we need to put extra effort
> to completely restrict it even if users want to explicitly list it
> into the publication.
>
> > >
> > > (In the below comments, clt/CLT implies Conflict Log Table)
> > >
> > > 1)
> > > 'select pg_relation_is_publishable(clt)' returns true for conflict-log table.
>
> This function is used while publishing every single change and I don't
> think we want to add a cost to check each subscription to identify
> whether the table is listed as CLT.
>
> > > 2)
> > > '\d+ clt' shows all-tables publication name. I feel we should not
> > > show that for clt.
>
> I think we should fix this.
>
> > > 3)
> > > I am able to create a publication for clt table, should it be allowed?
>
> I believe we should not do any specific handling to restrict this but
> I am open for the opinions.
>
> > > create subscription sub1 connection '...' publication pub1
> > > WITH(conflict_log_table='clt');
> > > create publication pub3 for table clt;
> > >
> > > 4)
> > > Is there a reason we have not made '!IsConflictHistoryRelid' check as
> > > part of is_publishable_class() itself? If we do so, other code-logics
> > > will also get clt as non-publishable always (and will solve a few of
> > > the above issues I think). IIUC, there is no place where we want to
> > > mark CLT as publishable or is there any?
>
> IMHO the main reason is performance.
>
> > > 5) Also, I feel we can add some documentation now to help others to
> > > understand/review the patch better without going through the long
> > > thread.
>
> Make sense, I will do that in the next version.
>
> > >
> > > Few observations related to conflict-logging:
> > > ------------------------------
> > > 1)
> > > I found that for the conflicts which ultimately result in Error, we do
> > > not insert any conflict-record in clt.
> > >
> > > a)
> > > Example: insert_exists, update_Exists
> > > create table tab1 (i int primary key, j int);
> > > sub: insert into tab1 values(30,10);
> > > pub: insert into tab1 values(30,10);
> > > ERROR: conflict detected on relation "public.tab1": conflict=insert_exists
> > > No record in clt.
> > >
> > > sub:
> > > <some pre-data needed>
> > > update tab1 set i=40 where i = 30;
> > > pub: update tab1 set i=40 where i = 20;
> > > ERROR: conflict detected on relation "public.tab1": conflict=update_exists
> > > No record in clt.
>
> Yeah that interesting need to put thought on how to commit this record
> when an outer transaction is aborted as we do not have autonomous
> transactions which are generally used for this kind of logging. But
> we can explore more options like inserting into conflict log tables
> outside the outer transaction.
>
> > > b)
> > > Another question related to this is, since these conflicts (which
> > > results in error) keep on happening until user resolves these or skips
> > > these or 'disable_on_error' is set. Then are we going to insert these
> > > multiple times? We do count these in 'confl_insert_exists' and
> > > 'confl_update_exists' everytime, so it makes sense to log those each
> > > time in clt as well. Thoughts?
>
> I think it make sense to insert every time we see the conflict, but it
> would be good to have opinion from others as well.
Since there is a concern that multiple rows for
multiple_unique_conflicts can cause data-bloat, it made me rethink
that this is actually more prone to causing data-bloat if it is not
resolved on time, as it seems a far more frequent scenario. So shall
we keep inserting the record or insert it once and avoid inserting it
again based on lsn? Thoughts?
>
> > > 2)
> > > Conflicts where row on sub is missing, local_ts incorrectly inserted.
> > > It is '2000-01-01 05:30:00+05:30'. Should it be Null or something
> > > indicating that it is not applicable for this conflict-type?
> > >
> > > Example: delete_missing, update_missing
> > > pub:
> > > insert into tab1 values(10,10);
> > > insert into tab1 values(20,10);
> > > sub: delete from tab1 where i=10;
> > > pub: delete from tab1 where i=10;
>
> Sure I will test this.
>
> >
> > 3)
> > We also need to think how we are going to display the info in case of
> > multiple_unique_conflicts as there could be multiple local and remote
> > tuples conflicting for one single operation. Example:
> >
> > create table conf_tab (a int primary key, b int unique, c int unique);
> >
> > sub: insert into conf_tab values (2,2,2), (3,3,3), (4,4,4);
> >
> > pub: insert into conf_tab values (2,3,4);
> >
> > ERROR: conflict detected on relation "public.conf_tab":
> > conflict=multiple_unique_conflicts
> > DETAIL: Key already exists in unique index "conf_tab_pkey", modified
> > locally in transaction 874 at 2025-11-12 14:35:13.452143+05:30.
> > Key (a)=(2); existing local row (2, 2, 2); remote row (2, 3, 4).
> > Key already exists in unique index "conf_tab_b_key", modified locally
> > in transaction 874 at 2025-11-12 14:35:13.452143+05:30.
> > Key (b)=(3); existing local row (3, 3, 3); remote row (2, 3, 4).
> > Key already exists in unique index "conf_tab_c_key", modified locally
> > in transaction 874 at 2025-11-12 14:35:13.452143+05:30.
> > Key (c)=(4); existing local row (4, 4, 4); remote row (2, 3, 4).
> > CONTEXT: processing remote data for replication origin "pg_16392"
> > during message type "INSERT" for replication target relation
> > "public.conf_tab" in transaction 781, finished at 0/017FDDA0
> >
> > Currently in clt, we have singular terms such as 'key_tuple',
> > 'local_tuple', 'remote_tuple'. Shall we have multiple rows inserted?
> > But it does not look reasonable to have multiple rows inserted for a
> > single conflict raised. I will think more about this.
>
> Currently I am inserting multiple records in the conflict history
> table, the same as each tuple is logged, but couldn't find any better
> way for this. Another option is to use an array of tuples instead of a
> single tuple but not sure this might make things more complicated to
> process by any external tool. But you are right, this needs more
> discussion.
>
> --
> Regards,
> Dilip Kumar
> Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-01 08:34 Dilip Kumar <dilipbalaut@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-12-01 08:34 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Mon, Dec 1, 2025 at 1:57 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> On Thu, Nov 13, 2025 at 9:17 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Thu, Nov 13, 2025 at 2:39 PM shveta malik <shveta.malik@gmail.com> wrote:
> > >
> > > > Few observations related to publication.
> > > > ------------------------------
> >
> > Thanks Shveta, for testing and sharing your thoughts. IMHO for
> > conflict log tables it should be good enough if we restrict it when
> > ALL TABLE options are used, I don't think we need to put extra effort
> > to completely restrict it even if users want to explicitly list it
> > into the publication.
> >
> > > >
> > > > (In the below comments, clt/CLT implies Conflict Log Table)
> > > >
> > > > 1)
> > > > 'select pg_relation_is_publishable(clt)' returns true for conflict-log table.
> >
> > This function is used while publishing every single change and I don't
> > think we want to add a cost to check each subscription to identify
> > whether the table is listed as CLT.
> >
> > > > 2)
> > > > '\d+ clt' shows all-tables publication name. I feel we should not
> > > > show that for clt.
> >
> > I think we should fix this.
> >
> > > > 3)
> > > > I am able to create a publication for clt table, should it be allowed?
> >
> > I believe we should not do any specific handling to restrict this but
> > I am open for the opinions.
> >
> > > > create subscription sub1 connection '...' publication pub1
> > > > WITH(conflict_log_table='clt');
> > > > create publication pub3 for table clt;
> > > >
> > > > 4)
> > > > Is there a reason we have not made '!IsConflictHistoryRelid' check as
> > > > part of is_publishable_class() itself? If we do so, other code-logics
> > > > will also get clt as non-publishable always (and will solve a few of
> > > > the above issues I think). IIUC, there is no place where we want to
> > > > mark CLT as publishable or is there any?
> >
> > IMHO the main reason is performance.
> >
> > > > 5) Also, I feel we can add some documentation now to help others to
> > > > understand/review the patch better without going through the long
> > > > thread.
> >
> > Make sense, I will do that in the next version.
> >
> > > >
> > > > Few observations related to conflict-logging:
> > > > ------------------------------
> > > > 1)
> > > > I found that for the conflicts which ultimately result in Error, we do
> > > > not insert any conflict-record in clt.
> > > >
> > > > a)
> > > > Example: insert_exists, update_Exists
> > > > create table tab1 (i int primary key, j int);
> > > > sub: insert into tab1 values(30,10);
> > > > pub: insert into tab1 values(30,10);
> > > > ERROR: conflict detected on relation "public.tab1": conflict=insert_exists
> > > > No record in clt.
> > > >
> > > > sub:
> > > > <some pre-data needed>
> > > > update tab1 set i=40 where i = 30;
> > > > pub: update tab1 set i=40 where i = 20;
> > > > ERROR: conflict detected on relation "public.tab1": conflict=update_exists
> > > > No record in clt.
> >
> > Yeah that interesting need to put thought on how to commit this record
> > when an outer transaction is aborted as we do not have autonomous
> > transactions which are generally used for this kind of logging. But
> > we can explore more options like inserting into conflict log tables
> > outside the outer transaction.
> >
> > > > b)
> > > > Another question related to this is, since these conflicts (which
> > > > results in error) keep on happening until user resolves these or skips
> > > > these or 'disable_on_error' is set. Then are we going to insert these
> > > > multiple times? We do count these in 'confl_insert_exists' and
> > > > 'confl_update_exists' everytime, so it makes sense to log those each
> > > > time in clt as well. Thoughts?
> >
> > I think it make sense to insert every time we see the conflict, but it
> > would be good to have opinion from others as well.
>
> Since there is a concern that multiple rows for
> multiple_unique_conflicts can cause data-bloat, it made me rethink
> that this is actually more prone to causing data-bloat if it is not
> resolved on time, as it seems a far more frequent scenario. So shall
> we keep inserting the record or insert it once and avoid inserting it
> again based on lsn? Thoughts?
I agree, this is the real problem related to bloat so maybe we can see
if the same tuple exists we can avoid inserting it again, although I
haven't put thought on how to we distinguish between the new conflict
on the same row vs the same conflict being inserted multiple times due
to worker restart.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-01 09:27 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: shveta malik @ 2025-12-01 09:27 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Mon, Dec 1, 2025 at 2:04 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Mon, Dec 1, 2025 at 1:57 PM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > On Thu, Nov 13, 2025 at 9:17 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > On Thu, Nov 13, 2025 at 2:39 PM shveta malik <shveta.malik@gmail.com> wrote:
> > > >
> > > > > Few observations related to publication.
> > > > > ------------------------------
> > >
> > > Thanks Shveta, for testing and sharing your thoughts. IMHO for
> > > conflict log tables it should be good enough if we restrict it when
> > > ALL TABLE options are used, I don't think we need to put extra effort
> > > to completely restrict it even if users want to explicitly list it
> > > into the publication.
> > >
> > > > >
> > > > > (In the below comments, clt/CLT implies Conflict Log Table)
> > > > >
> > > > > 1)
> > > > > 'select pg_relation_is_publishable(clt)' returns true for conflict-log table.
> > >
> > > This function is used while publishing every single change and I don't
> > > think we want to add a cost to check each subscription to identify
> > > whether the table is listed as CLT.
> > >
> > > > > 2)
> > > > > '\d+ clt' shows all-tables publication name. I feel we should not
> > > > > show that for clt.
> > >
> > > I think we should fix this.
> > >
> > > > > 3)
> > > > > I am able to create a publication for clt table, should it be allowed?
> > >
> > > I believe we should not do any specific handling to restrict this but
> > > I am open for the opinions.
> > >
> > > > > create subscription sub1 connection '...' publication pub1
> > > > > WITH(conflict_log_table='clt');
> > > > > create publication pub3 for table clt;
> > > > >
> > > > > 4)
> > > > > Is there a reason we have not made '!IsConflictHistoryRelid' check as
> > > > > part of is_publishable_class() itself? If we do so, other code-logics
> > > > > will also get clt as non-publishable always (and will solve a few of
> > > > > the above issues I think). IIUC, there is no place where we want to
> > > > > mark CLT as publishable or is there any?
> > >
> > > IMHO the main reason is performance.
> > >
> > > > > 5) Also, I feel we can add some documentation now to help others to
> > > > > understand/review the patch better without going through the long
> > > > > thread.
> > >
> > > Make sense, I will do that in the next version.
> > >
> > > > >
> > > > > Few observations related to conflict-logging:
> > > > > ------------------------------
> > > > > 1)
> > > > > I found that for the conflicts which ultimately result in Error, we do
> > > > > not insert any conflict-record in clt.
> > > > >
> > > > > a)
> > > > > Example: insert_exists, update_Exists
> > > > > create table tab1 (i int primary key, j int);
> > > > > sub: insert into tab1 values(30,10);
> > > > > pub: insert into tab1 values(30,10);
> > > > > ERROR: conflict detected on relation "public.tab1": conflict=insert_exists
> > > > > No record in clt.
> > > > >
> > > > > sub:
> > > > > <some pre-data needed>
> > > > > update tab1 set i=40 where i = 30;
> > > > > pub: update tab1 set i=40 where i = 20;
> > > > > ERROR: conflict detected on relation "public.tab1": conflict=update_exists
> > > > > No record in clt.
> > >
> > > Yeah that interesting need to put thought on how to commit this record
> > > when an outer transaction is aborted as we do not have autonomous
> > > transactions which are generally used for this kind of logging. But
> > > we can explore more options like inserting into conflict log tables
> > > outside the outer transaction.
> > >
> > > > > b)
> > > > > Another question related to this is, since these conflicts (which
> > > > > results in error) keep on happening until user resolves these or skips
> > > > > these or 'disable_on_error' is set. Then are we going to insert these
> > > > > multiple times? We do count these in 'confl_insert_exists' and
> > > > > 'confl_update_exists' everytime, so it makes sense to log those each
> > > > > time in clt as well. Thoughts?
> > >
> > > I think it make sense to insert every time we see the conflict, but it
> > > would be good to have opinion from others as well.
> >
> > Since there is a concern that multiple rows for
> > multiple_unique_conflicts can cause data-bloat, it made me rethink
> > that this is actually more prone to causing data-bloat if it is not
> > resolved on time, as it seems a far more frequent scenario. So shall
> > we keep inserting the record or insert it once and avoid inserting it
> > again based on lsn? Thoughts?
>
> I agree, this is the real problem related to bloat so maybe we can see
> if the same tuple exists we can avoid inserting it again, although I
> haven't put thought on how to we distinguish between the new conflict
> on the same row vs the same conflict being inserted multiple times due
> to worker restart.
>
If there is consensus on this approach, IMO, it appears safe to rely
on 'remote_origin' and 'remote_commit_lsn' as the comparison keys for
the given 'conflict_type' before we insert a new record.
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-01 09:41 Amit Kapila <amit.kapila16@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Amit Kapila @ 2025-12-01 09:41 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Dilip Kumar <dilipbalaut@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Mon, Dec 1, 2025 at 2:58 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> On Mon, Dec 1, 2025 at 2:04 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Mon, Dec 1, 2025 at 1:57 PM shveta malik <shveta.malik@gmail.com> wrote:
> > >
> > > Since there is a concern that multiple rows for
> > > multiple_unique_conflicts can cause data-bloat, it made me rethink
> > > that this is actually more prone to causing data-bloat if it is not
> > > resolved on time, as it seems a far more frequent scenario. So shall
> > > we keep inserting the record or insert it once and avoid inserting it
> > > again based on lsn? Thoughts?
> >
> > I agree, this is the real problem related to bloat so maybe we can see
> > if the same tuple exists we can avoid inserting it again, although I
> > haven't put thought on how to we distinguish between the new conflict
> > on the same row vs the same conflict being inserted multiple times due
> > to worker restart.
> >
>
> If there is consensus on this approach, IMO, it appears safe to rely
> on 'remote_origin' and 'remote_commit_lsn' as the comparison keys for
> the given 'conflict_type' before we insert a new record.
>
What happens if as part of multiple_unique_conflict, in the next apply
round only some of the rows conflict (say in the meantime user has
removed a few conflicting rows)? I think the ideal way for users to
avoid such multiple occurrences is to configure subscription with
disable_on_error. I think we should LOG errors again on retry and it
is better to keep it consistent with what we print in LOG because we
may want to give an option to users in future where to LOG (in
conflict_history_table, LOG, or both) the conflicts.
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-01 10:32 Dilip Kumar <dilipbalaut@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
0 siblings, 0 replies; 912+ messages in thread
From: Dilip Kumar @ 2025-12-01 10:32 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Mon, Dec 1, 2025 at 3:12 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Mon, Dec 1, 2025 at 2:58 PM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > On Mon, Dec 1, 2025 at 2:04 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > On Mon, Dec 1, 2025 at 1:57 PM shveta malik <shveta.malik@gmail.com> wrote:
> > > >
> > > > Since there is a concern that multiple rows for
> > > > multiple_unique_conflicts can cause data-bloat, it made me rethink
> > > > that this is actually more prone to causing data-bloat if it is not
> > > > resolved on time, as it seems a far more frequent scenario. So shall
> > > > we keep inserting the record or insert it once and avoid inserting it
> > > > again based on lsn? Thoughts?
> > >
> > > I agree, this is the real problem related to bloat so maybe we can see
> > > if the same tuple exists we can avoid inserting it again, although I
> > > haven't put thought on how to we distinguish between the new conflict
> > > on the same row vs the same conflict being inserted multiple times due
> > > to worker restart.
> > >
> >
> > If there is consensus on this approach, IMO, it appears safe to rely
> > on 'remote_origin' and 'remote_commit_lsn' as the comparison keys for
> > the given 'conflict_type' before we insert a new record.
> >
>
> What happens if as part of multiple_unique_conflict, in the next apply
> round only some of the rows conflict (say in the meantime user has
> removed a few conflicting rows)? I think the ideal way for users to
> avoid such multiple occurrences is to configure subscription with
> disable_on_error. I think we should LOG errors again on retry and it
> is better to keep it consistent with what we print in LOG because we
> may want to give an option to users in future where to LOG (in
> conflict_history_table, LOG, or both) the conflicts.
>
Yeah that makes sense, because if the user tried to fix the conflict
and if still didn't get fixed then next time onward user will have no
way to know that conflict reoccurred. And also it make sense to
maintain consistency with LOGs.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-01 10:52 Dilip Kumar <dilipbalaut@gmail.com>
parent: Peter Smith <smithpb2250@gmail.com>
0 siblings, 0 replies; 912+ messages in thread
From: Dilip Kumar @ 2025-12-01 10:52 UTC (permalink / raw)
To: Peter Smith <smithpb2250@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Fri, Nov 28, 2025 at 6:06 AM Peter Smith <smithpb2250@gmail.com> wrote:
>
> Some review comments for v8-0001.
Thank Peter, yes these all make sense and will fix in next version
along with other comments by Vignesh/Shveta and Amit, except one
comment
> 9.
> +-- ok - conflict_log_table should not be published with ALL TABLE
> +CREATE PUBLICATION pub FOR TABLES IN SCHEMA clt;
> +SELECT * FROM pg_publication_tables WHERE pubname = 'pub';
> + pubname | schemaname | tablename | attnames | rowfilter
> +---------+------------+-----------+----------+-----------
> +(0 rows)
>
> Perhaps you should repeat this same test but using FOR ALL TABLES,
> instead of only FOR TABLES IN SCHEMA
I will have to see how we can safely do this in testing without having
any side effects on the concurrent test, generally we run
publication.sql and subscription.sql concurrently in regression test
so if we do FOR ALL TABLES it can affect each others, one option is to
don't run these 2 test concurrently, I think we can do that as there
is no real concurrency we are testing by running them concurrently,
any thought on this?
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-02 05:07 Amit Kapila <amit.kapila16@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
1 sibling, 0 replies; 912+ messages in thread
From: Amit Kapila @ 2025-12-02 05:07 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Dilip Kumar <dilipbalaut@gmail.com>; Peter Smith <smithpb2250@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Fri, Nov 28, 2025 at 2:32 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> On Thu, Nov 27, 2025 at 5:50 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> >
> > I have fixed all these comments and also the comments of 0002, now I
> > feel we can actually merge 0001 and 0002, so I have merged both of
> > them.
> >
> > Now pending work status
> > 1) fixed review comments of 0003
> > 2) Run pgindent -- planning to do it after we complete the first level
> > of review
> > 3) Subscription TAP test for logging the actual conflicts
> >
>
> Thanks for the patch. A few observations:
>
> 1)
> It seems, as per LOG, 'key' and 'replica-identity' are different when
> it comes to insert_exists, update_exists and
> multiple_unique_conflicts, while I believe in CLT, key is
> replica-identity i.e. there are no 2 separate terms. Please see below:
>
> a)
> Update_Exists:
> 2025-11-28 14:08:56.179 IST [60383] ERROR: conflict detected on
> relation "public.tab1": conflict=update_exists
> 2025-11-28 14:08:56.179 IST [60383] DETAIL: Key already exists in
> unique index "tab1_pkey", modified locally in transaction 790 at
> 2025-11-28 14:07:17.578887+05:30.
> Key (i)=(40); existing local row (40, 10); remote row (40, 200);
> replica identity (i)=(20).
>
> postgres=# select conflict_type, key_tuple,local_tuple,remote_tuple
> from clt where conflict_type='update_exists';
> conflict_type | key_tuple | local_tuple | remote_tuple
> ---------------+-----------+-----------------+------------------
> update_exists | {"i":20} | {"i":40,"j":10} | {"i":40,"j":200}
>
> b)
> insert_Exists:
> ERROR: conflict detected on relation "public.tab1": conflict=insert_exists
> DETAIL: Key already exists in unique index "tab1_pkey", modified
> locally in transaction 767 at 2025-11-28 13:59:22.431097+05:30.
> Key (i)=(30); existing local row (30, 10); remote row (30, 10).
>
> postgres=# select conflict_type, key_tuple,local_tuple,remote_tuple from clt;
> conflict_type | key_tuple | local_tuple | remote_tuple
> ----------------+-----------+-----------------+-----------------
> insert_exists | | {"i":30,"j":10} | {"i":30,"j":10}
>
> case a) has key_tuple same as replica-identity of LOG
> case b) does not have replica-identity and thus key_tuple is NULL.
>
> Does that mean we need to maintain both key_tuple and RI separately in
> CLT? Thoughts?
>
Yeah, it could be useful to display RI values separately. What should
be the column name? Few options could be: remote_val_for_ri, or
remote_value_ri, or something else. I think it may also be useful to
display conflicting_index but OTOH, it would be difficult to decide in
the first version what other information could be required, so it is
better to stick with what is being displayed in LOG.
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-02 05:15 Amit Kapila <amit.kapila16@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
1 sibling, 0 replies; 912+ messages in thread
From: Amit Kapila @ 2025-12-02 05:15 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Wed, Nov 19, 2025 at 3:46 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Tue, Nov 18, 2025 at 4:47 PM shveta malik <shveta.malik@gmail.com> wrote:
> >
>
> > 3)
> > Do we need to have a timestamp column as well to say when conflict was
> > recorded? Or local_commit_ts, remote_commit_ts are sufficient?
> > Thoughts
>
> You mean we can record the timestamp now while inserting, not sure if
> it will add some more meaningful information than remote_commit_ts,
> but let's see what others think.
>
local_commit_ts and remote_commit_ts sounds sufficient as one can
identify the truth of information from those two. The key/schema
values displayed in this table could change later but the information
about a particular row is based on the time shown by those two
columns.
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-02 06:08 Amit Kapila <amit.kapila16@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Amit Kapila @ 2025-12-02 06:08 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Mon, Dec 1, 2025 at 10:11 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> The specific scenario we are discussing is when a single row from the
> publisher attempts to apply an operation that causes a conflict across
> multiple unique keys, with each of those unique key violations
> conflicting with a different local row on the subscriber, is very
> rare. IMHO this low-frequency scenario does not justify
> overcomplicating the design with an array field or a multi-level
> table.
>
I did some analysis and search on the internet to answer your
following two questions.
> Consider the infrequency of the root causes:
> - How often does a table have more than 3 to 4 unique keys?
It is extremely common—in fact, it is considered the industry "best
practice" for modern database design.
One can find this pattern in almost every enterprise system (e.g.
banking apps, CRMs). It relies on distinguishing between Technical
Identity (for the database) and Business Identity (for the real
world).
1. The Design Pattern: Surrogate vs. Natural Keys
Primary Key (Surrogate Key): Usually a meaningless number (e.g.,
10452) or a UUID. It is used strictly for the database to join tables
efficiently. It never changes.
Unique Key (Natural Key): A real-world value (e.g., john@email.com or
SSN-123). This is how humans or external systems identify the row. It
can change (e.g., someone updates their email).
2. Common Real-World Use Cases
A. User Management (The most classic example)
Primary Key: user_id (Integer). Used for foreign keys in the ORDERS table.
Unique Key 1: email (Varchar). Prevents two people from registering
with the same email.
Unique Key 2: username (Varchar). Ensures unique display names.
Why? If a user changes their email address, you only update one field
in one table. If you used email as the Primary Key, you would have to
update millions of rows in the ORDERS table that reference that email.
B. Inventory / E-Commerce
Primary Key: product_id (Integer). Used internally by the code.
Unique Key: SKU (Stock Keeping Unit) or Barcode (EAN/UPC).
Why? Companies often re-organize their SKU formats. If the SKU was the
Primary Key, a format change would require a massive database
migration.
C. Government / HR Systems
Primary Key: employee_id (Integer).
Unique Key: National_ID (SSN, Aadhaar, Passport Number).
Why? Privacy and security. You do not want to expose a National ID in
every URL or API call (e.g., api/employee/552 is safer than
api/employee/SSN-123).
> - How frequently would each of these keys conflict with a unique row
> on the subscriber side?
>
It can occur with medium-to-high probability in following cases. (a)
In Bi-Directional replication systems; for example, If two users
create the same "User Profile" on two different servers at the same
time, the row will conflict on every unique field (ID, Email, SSN)
simultaneously. (b) The chances of bloat are high, on retrying to fix
the error as mentioned by Shveta. Say, if Ops team fixes errors by
just "trying again" without checking the full row, you will hit the ID
error, fix it, then immediately hit the Email error. (c) The chances
are medium during initial data-load; If a user is loading data from a
legacy system with "dirty" data, rows often violate multiple rules
(e.g., a duplicate user with both a reused ID and a reused Email).
> If resolving this occasional, synthetic conflict requires inserting
> two or three rows instead of a single one, this is an acceptable
> trade-off considering how rare it can occur.
>
As per above analysis and the re-try point Shveta raises, I don't
think we can ignore the possibility of data-bloat especially for this
multiple_unique_key conflict. We can consider logging multiple local
conflicting rows as JSON Array.
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-02 06:36 Dilip Kumar <dilipbalaut@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-12-02 06:36 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, Dec 2, 2025 at 11:38 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Mon, Dec 1, 2025 at 10:11 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > The specific scenario we are discussing is when a single row from the
> > publisher attempts to apply an operation that causes a conflict across
> > multiple unique keys, with each of those unique key violations
> > conflicting with a different local row on the subscriber, is very
> > rare. IMHO this low-frequency scenario does not justify
> > overcomplicating the design with an array field or a multi-level
> > table.
> >
>
> I did some analysis and search on the internet to answer your
> following two questions.
>
> > Consider the infrequency of the root causes:
> > - How often does a table have more than 3 to 4 unique keys?
>
> It is extremely common—in fact, it is considered the industry "best
> practice" for modern database design.
>
> One can find this pattern in almost every enterprise system (e.g.
> banking apps, CRMs). It relies on distinguishing between Technical
> Identity (for the database) and Business Identity (for the real
> world).
>
> 1. The Design Pattern: Surrogate vs. Natural Keys
> Primary Key (Surrogate Key): Usually a meaningless number (e.g.,
> 10452) or a UUID. It is used strictly for the database to join tables
> efficiently. It never changes.
> Unique Key (Natural Key): A real-world value (e.g., john@email.com or
> SSN-123). This is how humans or external systems identify the row. It
> can change (e.g., someone updates their email).
>
> 2. Common Real-World Use Cases
> A. User Management (The most classic example)
> Primary Key: user_id (Integer). Used for foreign keys in the ORDERS table.
> Unique Key 1: email (Varchar). Prevents two people from registering
> with the same email.
> Unique Key 2: username (Varchar). Ensures unique display names.
> Why? If a user changes their email address, you only update one field
> in one table. If you used email as the Primary Key, you would have to
> update millions of rows in the ORDERS table that reference that email.
>
> B. Inventory / E-Commerce
> Primary Key: product_id (Integer). Used internally by the code.
> Unique Key: SKU (Stock Keeping Unit) or Barcode (EAN/UPC).
> Why? Companies often re-organize their SKU formats. If the SKU was the
> Primary Key, a format change would require a massive database
> migration.
>
> C. Government / HR Systems
> Primary Key: employee_id (Integer).
> Unique Key: National_ID (SSN, Aadhaar, Passport Number).
> Why? Privacy and security. You do not want to expose a National ID in
> every URL or API call (e.g., api/employee/552 is safer than
> api/employee/SSN-123).
>
> > - How frequently would each of these keys conflict with a unique row
> > on the subscriber side?
> >
>
> It can occur with medium-to-high probability in following cases. (a)
> In Bi-Directional replication systems; for example, If two users
> create the same "User Profile" on two different servers at the same
> time, the row will conflict on every unique field (ID, Email, SSN)
> simultaneously. (b) The chances of bloat are high, on retrying to fix
> the error as mentioned by Shveta. Say, if Ops team fixes errors by
> just "trying again" without checking the full row, you will hit the ID
> error, fix it, then immediately hit the Email error. (c) The chances
> are medium during initial data-load; If a user is loading data from a
> legacy system with "dirty" data, rows often violate multiple rules
> (e.g., a duplicate user with both a reused ID and a reused Email).
>
> > If resolving this occasional, synthetic conflict requires inserting
> > two or three rows instead of a single one, this is an acceptable
> > trade-off considering how rare it can occur.
> >
>
> As per above analysis and the re-try point Shveta raises, I don't
> think we can ignore the possibility of data-bloat especially for this
> multiple_unique_key conflict. We can consider logging multiple local
> conflicting rows as JSON Array.
Okay, I will try to make multiple local rows as JSON Array in the next version.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-02 07:08 Dilip Kumar <dilipbalaut@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-12-02 07:08 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, Dec 2, 2025 at 12:06 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Tue, Dec 2, 2025 at 11:38 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
> >
> > On Mon, Dec 1, 2025 at 10:11 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > The specific scenario we are discussing is when a single row from the
> > > publisher attempts to apply an operation that causes a conflict across
> > > multiple unique keys, with each of those unique key violations
> > > conflicting with a different local row on the subscriber, is very
> > > rare. IMHO this low-frequency scenario does not justify
> > > overcomplicating the design with an array field or a multi-level
> > > table.
> > >
> >
> > I did some analysis and search on the internet to answer your
> > following two questions.
> >
> > > Consider the infrequency of the root causes:
> > > - How often does a table have more than 3 to 4 unique keys?
> >
> > It is extremely common—in fact, it is considered the industry "best
> > practice" for modern database design.
> >
> > One can find this pattern in almost every enterprise system (e.g.
> > banking apps, CRMs). It relies on distinguishing between Technical
> > Identity (for the database) and Business Identity (for the real
> > world).
> >
> > 1. The Design Pattern: Surrogate vs. Natural Keys
> > Primary Key (Surrogate Key): Usually a meaningless number (e.g.,
> > 10452) or a UUID. It is used strictly for the database to join tables
> > efficiently. It never changes.
> > Unique Key (Natural Key): A real-world value (e.g., john@email.com or
> > SSN-123). This is how humans or external systems identify the row. It
> > can change (e.g., someone updates their email).
> >
> > 2. Common Real-World Use Cases
> > A. User Management (The most classic example)
> > Primary Key: user_id (Integer). Used for foreign keys in the ORDERS table.
> > Unique Key 1: email (Varchar). Prevents two people from registering
> > with the same email.
> > Unique Key 2: username (Varchar). Ensures unique display names.
> > Why? If a user changes their email address, you only update one field
> > in one table. If you used email as the Primary Key, you would have to
> > update millions of rows in the ORDERS table that reference that email.
> >
> > B. Inventory / E-Commerce
> > Primary Key: product_id (Integer). Used internally by the code.
> > Unique Key: SKU (Stock Keeping Unit) or Barcode (EAN/UPC).
> > Why? Companies often re-organize their SKU formats. If the SKU was the
> > Primary Key, a format change would require a massive database
> > migration.
> >
> > C. Government / HR Systems
> > Primary Key: employee_id (Integer).
> > Unique Key: National_ID (SSN, Aadhaar, Passport Number).
> > Why? Privacy and security. You do not want to expose a National ID in
> > every URL or API call (e.g., api/employee/552 is safer than
> > api/employee/SSN-123).
> >
> > > - How frequently would each of these keys conflict with a unique row
> > > on the subscriber side?
> > >
> >
> > It can occur with medium-to-high probability in following cases. (a)
> > In Bi-Directional replication systems; for example, If two users
> > create the same "User Profile" on two different servers at the same
> > time, the row will conflict on every unique field (ID, Email, SSN)
> > simultaneously. (b) The chances of bloat are high, on retrying to fix
> > the error as mentioned by Shveta. Say, if Ops team fixes errors by
> > just "trying again" without checking the full row, you will hit the ID
> > error, fix it, then immediately hit the Email error. (c) The chances
> > are medium during initial data-load; If a user is loading data from a
> > legacy system with "dirty" data, rows often violate multiple rules
> > (e.g., a duplicate user with both a reused ID and a reused Email).
> >
> > > If resolving this occasional, synthetic conflict requires inserting
> > > two or three rows instead of a single one, this is an acceptable
> > > trade-off considering how rare it can occur.
> > >
> >
> > As per above analysis and the re-try point Shveta raises, I don't
> > think we can ignore the possibility of data-bloat especially for this
> > multiple_unique_key conflict. We can consider logging multiple local
> > conflicting rows as JSON Array.
>
> Okay, I will try to make multiple local rows as JSON Array in the next version.
>
Just to clarify so that we are on the same page, along with the local
tuple the other local fields like local_xid, local_commit_ts,
local_origin will also be converted into the array. Hope that makes
sense?
So we will change the table like this, not sure if this makes sense to
keep all local array fields nearby in the table, or let it be near the
respective remote field, like we are doing now remote_xid and local
xid together etc.
Column | Type | Collation | Nullable | Default
-------------------+--------------------------+-----------+----------+---------
relid | oid | | |
schemaname | text | | |
relname | text | | |
conflict_type | text | | |
local_xid | xid[] | | |
remote_xid | xid | | |
remote_commit_lsn | pg_lsn | | |
local_commit_ts | timestamp with time zone[] | | |
remote_commit_ts | timestamp with time zone | | |
local_origin | text[] | | |
remote_origin | text | | |
key_tuple | json | | |
local_tuple | json[] | | |
remote_tuple | json | | |
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-02 09:17 Amit Kapila <amit.kapila16@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Amit Kapila @ 2025-12-02 09:17 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, Dec 2, 2025 at 12:38 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Tue, Dec 2, 2025 at 12:06 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> >
> > Okay, I will try to make multiple local rows as JSON Array in the next version.
> >
> Just to clarify so that we are on the same page, along with the local
> tuple the other local fields like local_xid, local_commit_ts,
> local_origin will also be converted into the array. Hope that makes
> sense?
>
Yes, what about key_tuple or RI?
> So we will change the table like this, not sure if this makes sense to
> keep all local array fields nearby in the table, or let it be near the
> respective remote field, like we are doing now remote_xid and local
> xid together etc.
>
It is better to keep the array fields together at the end. I think it
would be better to read via CLI. Also, it may take more space due to
padding/alignment if we store fixed-width and variable-width columns
interleaved and similarly the access will also be slower for
interleaved cases.
Having said that, can we consider an alternative way to store all
local_conflict_info together as a JSONB column (that can be used to
store an array of objects). For example, the multiple conflicting
tuple information can be stored as:
[
{ "xid": "1001", "commit_ts": "2023-10-27 10:00:00", "origin":
"node_A", "tuple": { "id": 1, "email": "a@b.com" } },
{ "xid": "1005", "commit_ts": "2023-10-27 10:01:00", "origin":
"node_B", "tuple": { "id": 2, "phone": "555-0199" } }
]
To access JSON array columns, I think one needs to use the unnest
function, whereas JSONB could be accessed with something like: "SELECT
* FROM conflicts WHERE local_conflicts @> '[{"xid": "1001"}]".
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-02 11:15 Dilip Kumar <dilipbalaut@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-12-02 11:15 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, Dec 2, 2025 at 2:47 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Tue, Dec 2, 2025 at 12:38 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Tue, Dec 2, 2025 at 12:06 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > >
> > > Okay, I will try to make multiple local rows as JSON Array in the next version.
> > >
> > Just to clarify so that we are on the same page, along with the local
> > tuple the other local fields like local_xid, local_commit_ts,
> > local_origin will also be converted into the array. Hope that makes
> > sense?
> >
>
> Yes, what about key_tuple or RI?
>
> > So we will change the table like this, not sure if this makes sense to
> > keep all local array fields nearby in the table, or let it be near the
> > respective remote field, like we are doing now remote_xid and local
> > xid together etc.
> >
>
> It is better to keep the array fields together at the end. I think it
> would be better to read via CLI. Also, it may take more space due to
> padding/alignment if we store fixed-width and variable-width columns
> interleaved and similarly the access will also be slower for
> interleaved cases.
>
> Having said that, can we consider an alternative way to store all
> local_conflict_info together as a JSONB column (that can be used to
> store an array of objects). For example, the multiple conflicting
> tuple information can be stored as:
>
> [
> { "xid": "1001", "commit_ts": "2023-10-27 10:00:00", "origin":
> "node_A", "tuple": { "id": 1, "email": "a@b.com" } },
> { "xid": "1005", "commit_ts": "2023-10-27 10:01:00", "origin":
> "node_B", "tuple": { "id": 2, "phone": "555-0199" } }
> ]
>
> To access JSON array columns, I think one needs to use the unnest
> function, whereas JSONB could be accessed with something like: "SELECT
> * FROM conflicts WHERE local_conflicts @> '[{"xid": "1001"}]".
Yeah we can do that as well, maybe that's a better idea compared to
creating separate array fields for each local element.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-02 15:10 Dilip Kumar <dilipbalaut@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-12-02 15:10 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, Dec 2, 2025 at 4:45 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Tue, Dec 2, 2025 at 2:47 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> >
> > On Tue, Dec 2, 2025 at 12:38 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > On Tue, Dec 2, 2025 at 12:06 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > >
> > > >
> > > > Okay, I will try to make multiple local rows as JSON Array in the next version.
> > > >
> > > Just to clarify so that we are on the same page, along with the local
> > > tuple the other local fields like local_xid, local_commit_ts,
> > > local_origin will also be converted into the array. Hope that makes
> > > sense?
> > >
> >
> > Yes, what about key_tuple or RI?
> >
> > > So we will change the table like this, not sure if this makes sense to
> > > keep all local array fields nearby in the table, or let it be near the
> > > respective remote field, like we are doing now remote_xid and local
> > > xid together etc.
> > >
> >
> > It is better to keep the array fields together at the end. I think it
> > would be better to read via CLI. Also, it may take more space due to
> > padding/alignment if we store fixed-width and variable-width columns
> > interleaved and similarly the access will also be slower for
> > interleaved cases.
> >
> > Having said that, can we consider an alternative way to store all
> > local_conflict_info together as a JSONB column (that can be used to
> > store an array of objects). For example, the multiple conflicting
> > tuple information can be stored as:
> >
> > [
> > { "xid": "1001", "commit_ts": "2023-10-27 10:00:00", "origin":
> > "node_A", "tuple": { "id": 1, "email": "a@b.com" } },
> > { "xid": "1005", "commit_ts": "2023-10-27 10:01:00", "origin":
> > "node_B", "tuple": { "id": 2, "phone": "555-0199" } }
> > ]
> >
> > To access JSON array columns, I think one needs to use the unnest
> > function, whereas JSONB could be accessed with something like: "SELECT
> > * FROM conflicts WHERE local_conflicts @> '[{"xid": "1001"}]".
>
> Yeah we can do that as well, maybe that's a better idea compared to
> creating separate array fields for each local element.
So I tried the POC idea with this approach and tested with one of the
test cases given by Shveta, and now the conflict log table entry looks
like this. So we can see the local conflicts field which is an array
of JSON and each entry of the array is formed using (xid, commit_ts,
origin, json tuple). I will send the updated patch by tomorrow after
doing some more cleanup and testing.
relid | 16391
schemaname | public
relname | conf_tab
conflict_type | multiple_unique_conflicts
remote_xid | 761
remote_commit_lsn | 0/01761400
remote_commit_ts | 2025-12-02 15:02:07.045935+00
remote_origin | pg_16406
key_tuple |
remote_tuple | {"a":2,"b":3,"c":4}
local_conflicts |
{"{\"xid\":\"773\",\"commit_ts\":\"2025-12-02T15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":2,\"b\":2,\"c\":2}}","{\"xid\":\"
773\",\"commit_ts\":\"2025-12-02T15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":3,\"b\":3,\"c\":3}}","{\"xid\":\"773\",\"commit_ts\":\"2025-12-02T
15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":4,\"b\":4,\"c\":4}}"}
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-03 04:19 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: shveta malik @ 2025-12-03 04:19 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Tue, Dec 2, 2025 at 8:40 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Tue, Dec 2, 2025 at 4:45 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Tue, Dec 2, 2025 at 2:47 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > >
> > > On Tue, Dec 2, 2025 at 12:38 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > >
> > > > On Tue, Dec 2, 2025 at 12:06 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > > >
> > > > >
> > > > > Okay, I will try to make multiple local rows as JSON Array in the next version.
> > > > >
> > > > Just to clarify so that we are on the same page, along with the local
> > > > tuple the other local fields like local_xid, local_commit_ts,
> > > > local_origin will also be converted into the array. Hope that makes
> > > > sense?
> > > >
> > >
> > > Yes, what about key_tuple or RI?
> > >
> > > > So we will change the table like this, not sure if this makes sense to
> > > > keep all local array fields nearby in the table, or let it be near the
> > > > respective remote field, like we are doing now remote_xid and local
> > > > xid together etc.
> > > >
> > >
> > > It is better to keep the array fields together at the end. I think it
> > > would be better to read via CLI. Also, it may take more space due to
> > > padding/alignment if we store fixed-width and variable-width columns
> > > interleaved and similarly the access will also be slower for
> > > interleaved cases.
> > >
> > > Having said that, can we consider an alternative way to store all
> > > local_conflict_info together as a JSONB column (that can be used to
> > > store an array of objects). For example, the multiple conflicting
> > > tuple information can be stored as:
> > >
> > > [
> > > { "xid": "1001", "commit_ts": "2023-10-27 10:00:00", "origin":
> > > "node_A", "tuple": { "id": 1, "email": "a@b.com" } },
> > > { "xid": "1005", "commit_ts": "2023-10-27 10:01:00", "origin":
> > > "node_B", "tuple": { "id": 2, "phone": "555-0199" } }
> > > ]
> > >
> > > To access JSON array columns, I think one needs to use the unnest
> > > function, whereas JSONB could be accessed with something like: "SELECT
> > > * FROM conflicts WHERE local_conflicts @> '[{"xid": "1001"}]".
> >
> > Yeah we can do that as well, maybe that's a better idea compared to
> > creating separate array fields for each local element.
>
> So I tried the POC idea with this approach and tested with one of the
> test cases given by Shveta, and now the conflict log table entry looks
> like this. So we can see the local conflicts field which is an array
> of JSON and each entry of the array is formed using (xid, commit_ts,
> origin, json tuple). I will send the updated patch by tomorrow after
> doing some more cleanup and testing.
>
> relid | 16391
> schemaname | public
> relname | conf_tab
> conflict_type | multiple_unique_conflicts
> remote_xid | 761
> remote_commit_lsn | 0/01761400
> remote_commit_ts | 2025-12-02 15:02:07.045935+00
> remote_origin | pg_16406
> key_tuple |
> remote_tuple | {"a":2,"b":3,"c":4}
> local_conflicts |
> {"{\"xid\":\"773\",\"commit_ts\":\"2025-12-02T15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":2,\"b\":2,\"c\":2}}","{\"xid\":\"
> 773\",\"commit_ts\":\"2025-12-02T15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":3,\"b\":3,\"c\":3}}","{\"xid\":\"773\",\"commit_ts\":\"2025-12-02T
> 15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":4,\"b\":4,\"c\":4}}"}
>
Thanks, it looks good. For the benefit of others, could you include a
brief note, perhaps in the commit message for now, describing how to
access or read this array column? We can remove it later.
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-03 11:26 Dilip Kumar <dilipbalaut@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 5 replies; 912+ messages in thread
From: Dilip Kumar @ 2025-12-03 11:26 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Wed, Dec 3, 2025 at 9:49 AM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > relid | 16391
> > schemaname | public
> > relname | conf_tab
> > conflict_type | multiple_unique_conflicts
> > remote_xid | 761
> > remote_commit_lsn | 0/01761400
> > remote_commit_ts | 2025-12-02 15:02:07.045935+00
> > remote_origin | pg_16406
> > key_tuple |
> > remote_tuple | {"a":2,"b":3,"c":4}
> > local_conflicts |
> > {"{\"xid\":\"773\",\"commit_ts\":\"2025-12-02T15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":2,\"b\":2,\"c\":2}}","{\"xid\":\"
> > 773\",\"commit_ts\":\"2025-12-02T15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":3,\"b\":3,\"c\":3}}","{\"xid\":\"773\",\"commit_ts\":\"2025-12-02T
> > 15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":4,\"b\":4,\"c\":4}}"}
> >
>
> Thanks, it looks good. For the benefit of others, could you include a
> brief note, perhaps in the commit message for now, describing how to
> access or read this array column? We can remove it later.
Thanks, okay, temporarily I have added in a commit message how we can
fetch the data from the JSON array field. In next version I will add
a test to get the conflict stored in conflict log history table and
fetch from it.
--
Regards,
Dilip Kumar
Google
Attachments:
[application/octet-stream] v9-0001-Add-configurable-conflict-log-table-for-Logical-R.patch (113.1K, ../../CAFiTN-uR=86L_5tyiA7n73EXCSCuDfQKfL5O=c8n7zZom8_ONQ@mail.gmail.com/2-v9-0001-Add-configurable-conflict-log-table-for-Logical-R.patch)
download | inline diff:
From 1545fbec70859eced3bfe97b3ac0261bdb069d3a Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 12 Nov 2025 10:43:19 +0530
Subject: [PATCH v9] Add configurable conflict log table for Logical
Replication
This patch adds a feature to provide a structured, queryable record of all
logical replication conflicts. The current approach of logging conflicts as
plain text in the server logs makes it difficult to query, analyze, and
use for external monitoring and automation.
This patch addresses these limitations by introducing a configurable
conflict_log_table option in the CREATE SUBSCRIPTION command. Key design
decisions include:
User-Managed Table: The conflict log is stored in a user-managed table
rather than a system catalog.
Structured Data: Conflict details, including the original and remote tuples,
are stored in JSON columns, providing a flexible format to accommodate different
table schemas.
Comprehensive Information: The log table captures essential attributes such as
local and remote transaction IDs, LSNs, commit timestamps, and conflict type,
providing a complete record for post-mortem analysis.
This feature will make logical replication conflicts easier to monitor and manage,
significantly improving the overall resilience and operability of replication setups.
The conflict log tables will not be included in a publication, even if the publication
is configured to include ALL TABLES or ALL TABLES IN SCHEMA.
Note: A single remote tuple may conflict with multiple local conflict when conflict type
is CT_MULTIPLE_UNIQUE_CONFLICTS, so for handling this case we create a single row in
conflict log table with respect to each remote conflict row even if it conflicts with
multiple local rows and we store the multiple conflict tuples as a single JSON array
element in format as
[ { "xid": "1001", "commit_ts": "...", "origin": "...", "tuple": {...} }, ... ]
We can extract the elements from local tuple as given in below example
SELECT remote_xid, relname, remote_origin, local_conflicts[1] ->> 'xid' AS local_xid,
local_conflicts[1] ->> 'tuple' AS local_tuple
FROM myschema.conflict_log_history2;
remote_xid | relname | remote_origin | local_xid | local_tuple
------------+----------+---------------+-----------+---------------------
760 | test | pg_16406 | 771 | {"a":1,"b":10}
765 | conf_tab | pg_16406 | 775 | {"a":2,"b":2,"c":2}
---
src/backend/catalog/pg_publication.c | 26 +-
src/backend/commands/subscriptioncmds.c | 236 ++++++++++-
src/backend/replication/logical/conflict.c | 454 +++++++++++++++++++--
src/backend/replication/logical/launcher.c | 1 +
src/backend/replication/logical/worker.c | 30 +-
src/backend/utils/cache/lsyscache.c | 38 ++
src/bin/psql/describe.c | 8 +-
src/bin/psql/tab-complete.in.c | 8 +-
src/include/catalog/pg_subscription.h | 5 +
src/include/commands/subscriptioncmds.h | 2 +
src/include/replication/conflict.h | 4 +
src/include/replication/worker_internal.h | 7 +
src/include/utils/lsyscache.h | 1 +
src/test/regress/expected/subscription.out | 319 +++++++++++----
src/test/regress/sql/subscription.sql | 87 ++++
15 files changed, 1104 insertions(+), 122 deletions(-)
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index 0994220c53d..a0efac9fa50 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -31,6 +31,7 @@
#include "catalog/pg_publication_rel.h"
#include "catalog/pg_type.h"
#include "commands/publicationcmds.h"
+#include "commands/subscriptioncmds.h"
#include "funcapi.h"
#include "utils/array.h"
#include "utils/builtins.h"
@@ -85,6 +86,15 @@ check_publication_add_relation(Relation targetrel)
errmsg("cannot add relation \"%s\" to publication",
RelationGetRelationName(targetrel)),
errdetail("This operation is not supported for unlogged tables.")));
+
+ /* Can't be conflict log table */
+ if (IsConflictLogTable(RelationGetRelid(targetrel)))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot add relation \"%s.%s\" to publication",
+ get_namespace_name(RelationGetNamespace(targetrel)),
+ RelationGetRelationName(targetrel)),
+ errdetail("This operation is not supported for conflict log tables.")));
}
/*
@@ -145,6 +155,13 @@ is_publishable_class(Oid relid, Form_pg_class reltuple)
/*
* Another variant of is_publishable_class(), taking a Relation.
+ *
+ * Note: Conflict log tables are not publishable. However, we intentionally
+ * skip this check here because this function is called for every change and
+ * performing this check during every change publication is costly. To ensure
+ * unpublishable entries are ignored without incurring performance overhead,
+ * tuples inserted into the conflict log table uses the HEAP_INSERT_NO_LOGICAL
+ * flag. This allows the decoding layer to bypass these entries automatically.
*/
bool
is_publishable_relation(Relation rel)
@@ -169,7 +186,10 @@ pg_relation_is_publishable(PG_FUNCTION_ARGS)
tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
if (!HeapTupleIsValid(tuple))
PG_RETURN_NULL();
- result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple));
+
+ /* Subscription conflict log tables are not published */
+ result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple)) &&
+ !IsConflictLogTable(relid);
ReleaseSysCache(tuple);
PG_RETURN_BOOL(result);
}
@@ -890,7 +910,9 @@ GetAllPublicationRelations(char relkind, bool pubviaroot)
Form_pg_class relForm = (Form_pg_class) GETSTRUCT(tuple);
Oid relid = relForm->oid;
+ /* Subscription conflict log tables are not published */
if (is_publishable_class(relid, relForm) &&
+ !IsConflictLogTable(relid) &&
!(relForm->relispartition && pubviaroot))
result = lappend_oid(result, relid);
}
@@ -1018,7 +1040,7 @@ GetSchemaPublicationRelations(Oid schemaid, PublicationPartOpt pub_partopt)
Oid relid = relForm->oid;
char relkind;
- if (!is_publishable_class(relid, relForm))
+ if (!is_publishable_class(relid, relForm) || IsConflictLogTable(relid))
continue;
relkind = get_rel_relkind(relid);
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index 24b70234b35..47d06109d90 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -15,6 +15,7 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/htup_details.h"
#include "access/table.h"
#include "access/twophase.h"
@@ -34,6 +35,7 @@
#include "commands/event_trigger.h"
#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "executor/spi.h"
#include "miscadmin.h"
#include "nodes/makefuncs.h"
#include "pgstat.h"
@@ -47,10 +49,12 @@
#include "storage/lmgr.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/guc.h"
#include "utils/lsyscache.h"
#include "utils/memutils.h"
#include "utils/pg_lsn.h"
+#include "utils/regproc.h"
#include "utils/syscache.h"
/*
@@ -75,6 +79,7 @@
#define SUBOPT_MAX_RETENTION_DURATION 0x00008000
#define SUBOPT_LSN 0x00010000
#define SUBOPT_ORIGIN 0x00020000
+#define SUBOPT_CONFLICT_LOG_TABLE 0x00040000
/* check if the 'val' has 'bits' set */
#define IsSet(val, bits) (((val) & (bits)) == (bits))
@@ -103,6 +108,7 @@ typedef struct SubOpts
bool retaindeadtuples;
int32 maxretention;
char *origin;
+ char *conflictlogtable;
XLogRecPtr lsn;
} SubOpts;
@@ -135,7 +141,8 @@ static List *merge_publications(List *oldpublist, List *newpublist, bool addpub,
static void ReportSlotConnectionError(List *rstates, Oid subid, char *slotname, char *err);
static void CheckAlterSubOption(Subscription *sub, const char *option,
bool slot_needs_update, bool isTopLevel);
-
+static void create_conflict_log_table(Oid namespaceId, char *conflictrel);
+static void drop_conflict_log_table(Oid namespaceId, char *conflictrel);
/*
* Common option parsing function for CREATE and ALTER SUBSCRIPTION commands.
@@ -191,6 +198,8 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->maxretention = 0;
if (IsSet(supported_opts, SUBOPT_ORIGIN))
opts->origin = pstrdup(LOGICALREP_ORIGIN_ANY);
+ if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_TABLE))
+ opts->conflictlogtable = NULL;
/* Parse options */
foreach(lc, stmt_options)
@@ -402,6 +411,15 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->specified_opts |= SUBOPT_LSN;
opts->lsn = lsn;
}
+ else if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_TABLE) &&
+ strcmp(defel->defname, "conflict_log_table") == 0)
+ {
+ if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_LOG_TABLE))
+ errorConflictingDefElem(defel, pstate);
+
+ opts->specified_opts |= SUBOPT_CONFLICT_LOG_TABLE;
+ opts->conflictlogtable = defGetString(defel);
+ }
else
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
@@ -599,6 +617,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bits32 supported_opts;
SubOpts opts = {0};
AclResult aclresult;
+ Oid conflictlogtable_nspid = InvalidOid;
+ char *conflictlogtable = NULL;
/*
* Parse and check options.
@@ -612,7 +632,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
SUBOPT_DISABLE_ON_ERR | SUBOPT_PASSWORD_REQUIRED |
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
- SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN);
+ SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_TABLE);
parse_subscription_options(pstate, stmt->options, supported_opts, &opts);
/*
@@ -747,6 +768,25 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
values[Anum_pg_subscription_suborigin - 1] =
CStringGetTextDatum(opts.origin);
+ /*
+ * If a conflict log table name is specified, parse the schema and table
+ * name from the string. Store the namespace OID and the table name in
+ * the pg_subscription catalog tuple.
+ */
+ if (opts.conflictlogtable)
+ {
+ List *names = stringToQualifiedNameList(opts.conflictlogtable, NULL);
+
+ conflictlogtable_nspid =
+ QualifiedNameGetCreationNamespace(names, &conflictlogtable);
+ values[Anum_pg_subscription_subconflictlognspid - 1] =
+ ObjectIdGetDatum(conflictlogtable_nspid);
+ values[Anum_pg_subscription_subconflictlogtable - 1] =
+ CStringGetTextDatum(conflictlogtable);
+ }
+ else
+ nulls[Anum_pg_subscription_subconflictlogtable - 1] = true;
+
tup = heap_form_tuple(RelationGetDescr(rel), values, nulls);
/* Insert tuple into catalog. */
@@ -768,6 +808,10 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
ReplicationOriginNameForLogicalRep(subid, InvalidOid, originname, sizeof(originname));
replorigin_create(originname);
+ /* If a conflict log table name is given then create the table. */
+ if (opts.conflictlogtable)
+ create_conflict_log_table(conflictlogtable_nspid, conflictlogtable);
+
/*
* Connect to remote side to execute requested commands and fetch table
* and sequence info.
@@ -1410,7 +1454,8 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
SUBOPT_MAX_RETENTION_DURATION |
- SUBOPT_ORIGIN);
+ SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_TABLE);
parse_subscription_options(pstate, stmt->options,
supported_opts, &opts);
@@ -1665,6 +1710,59 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
origin = opts.origin;
}
+ if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_LOG_TABLE))
+ {
+ Oid nspid;
+ char *relname = NULL;
+ Oid old_nspid = InvalidOid;
+ char *old_relname = NULL;
+ List *names =
+ stringToQualifiedNameList(opts.conflictlogtable, NULL);
+
+ nspid = QualifiedNameGetCreationNamespace(names, &relname);
+
+ /*
+ * If the subscription already uses this conflict log table
+ * and it exists, just issue a notice.
+ */
+ old_relname =
+ get_subscription_conflict_log_table(subid, &old_nspid);
+ if (old_relname != NULL &&
+ strcmp(old_relname, relname) == 0 &&
+ old_nspid == nspid &&
+ OidIsValid(get_relname_relid(relname, nspid)))
+ {
+ char *nspname = get_namespace_name(nspid);
+
+ ereport(NOTICE,
+ (errmsg("\"%s.%s\" is already in use as the conflict log table for this subscription",
+ nspname, relname)));
+ pfree(nspname);
+ }
+ else
+ {
+ /*
+ * Create the conflict log table after dropping any
+ * pre-existing one.
+ */
+ if (old_relname)
+ drop_conflict_log_table(old_nspid, old_relname);
+ create_conflict_log_table(nspid, relname);
+
+ values[Anum_pg_subscription_subconflictlognspid - 1] =
+ ObjectIdGetDatum(nspid);
+ values[Anum_pg_subscription_subconflictlogtable - 1] =
+ CStringGetTextDatum(relname);
+ replaces[Anum_pg_subscription_subconflictlognspid - 1] =
+ true;
+ replaces[Anum_pg_subscription_subconflictlogtable - 1] =
+ true;
+ }
+
+ if (old_relname != NULL)
+ pfree(old_relname);
+ }
+
update_tuple = true;
break;
}
@@ -2027,6 +2125,8 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
Form_pg_subscription form;
List *rstates;
bool must_use_password;
+ Oid conflictlogtable_nsp = InvalidOid;
+ char *conflictlogtable = NULL;
/*
* The launcher may concurrently start a new worker for this subscription.
@@ -2110,6 +2210,20 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
ObjectAddressSet(myself, SubscriptionRelationId, subid);
EventTriggerSQLDropAddObject(&myself, true, true);
+ /* Fetch the conflict log table information. */
+ conflictlogtable =
+ get_subscription_conflict_log_table(subid, &conflictlogtable_nsp);
+
+ /*
+ * If the subscription had a conflict log table, drop it now. This happens
+ * before deleting the subscription tuple.
+ */
+ if (conflictlogtable)
+ {
+ drop_conflict_log_table(conflictlogtable_nsp, conflictlogtable);
+ pfree(conflictlogtable);
+ }
+
/* Remove the tuple from catalog. */
CatalogTupleDelete(rel, &tup->t_self);
@@ -3188,3 +3302,119 @@ defGetStreamingMode(DefElem *def)
def->defname)));
return LOGICALREP_STREAM_OFF; /* keep compiler quiet */
}
+
+/*
+ * Create conflict log table.
+ *
+ * The subscription owner becomes the owner of this table and has all
+ * privileges on it.
+ */
+static void
+create_conflict_log_table(Oid namespaceId, char *conflictrel)
+{
+ StringInfoData querybuf;
+
+ /* Report an error if the specified conflict log table already exists. */
+ if (OidIsValid(get_relname_relid(conflictrel, namespaceId)))
+ ereport(ERROR,
+ (errcode(ERRCODE_DUPLICATE_TABLE),
+ errmsg("cannot create conflict log table \"%s.%s\" because a table with that name already exists",
+ get_namespace_name(namespaceId), conflictrel),
+ errhint("Use a different name for the conflict log table or drop the existing table.")));
+
+ initStringInfo(&querybuf);
+
+ /* build and execute the CREATE TABLE query. */
+ appendStringInfo(&querybuf,
+ "CREATE TABLE %s.%s ("
+ "relid Oid,"
+ "schemaname TEXT,"
+ "relname TEXT,"
+ "conflict_type TEXT,"
+ "remote_xid xid,"
+ "remote_commit_lsn pg_lsn,"
+ "remote_commit_ts TIMESTAMPTZ,"
+ "remote_origin TEXT,"
+ "key_tuple JSON,"
+ "remote_tuple JSON,"
+ "local_conflicts JSON[])",
+ quote_identifier(get_namespace_name(namespaceId)),
+ quote_identifier(conflictrel));
+
+ if (SPI_connect() != SPI_OK_CONNECT)
+ elog(ERROR, "SPI_connect failed");
+
+ if (SPI_execute(querybuf.data, false, 0) != SPI_OK_UTILITY)
+ elog(ERROR, "SPI_exec failed: %s", querybuf.data);
+
+ if (SPI_finish() != SPI_OK_FINISH)
+ elog(ERROR, "SPI_finish failed");
+
+ pfree(querybuf.data);
+}
+
+/*
+ * Drop the conflict log table.
+ *
+ * This function uses SPI to execute DROP TABLE IF EXISTS.
+ * We use IF EXISTS to avoid errors if the user manually dropped it first.
+ */
+static void
+drop_conflict_log_table(Oid namespaceId, char *conflictrel)
+{
+ StringInfoData querybuf;
+
+ initStringInfo(&querybuf);
+
+ /* Drop the conflict log table if it exists. */
+ appendStringInfo(&querybuf,
+ "DROP TABLE IF EXISTS %s.%s",
+ quote_identifier(get_namespace_name(namespaceId)),
+ quote_identifier(conflictrel));
+
+ if (SPI_connect() != SPI_OK_CONNECT)
+ elog(ERROR, "SPI_connect failed");
+
+ if (SPI_execute(querybuf.data, false, 0) != SPI_OK_UTILITY)
+ elog(ERROR, "SPI_exec failed: %s", querybuf.data);
+
+ if (SPI_finish() != SPI_OK_FINISH)
+ elog(ERROR, "SPI_finish failed");
+
+ pfree(querybuf.data);
+}
+
+/*
+ * Check if the specified relation is used as a conflict log table by any
+ * subscription.
+ */
+bool
+IsConflictLogTable(Oid relid)
+{
+ Relation rel;
+ TableScanDesc scan;
+ HeapTuple tup;
+ bool is_clt = false;
+
+ rel = table_open(SubscriptionRelationId, AccessShareLock);
+ scan = table_beginscan_catalog(rel, 0, NULL);
+
+ while (HeapTupleIsValid(tup = heap_getnext(scan, ForwardScanDirection)))
+ {
+ Form_pg_subscription subform = (Form_pg_subscription) GETSTRUCT(tup);
+ Oid nspid;
+ char *relname;
+
+ relname = get_subscription_conflict_log_table(subform->oid, &nspid);
+ if (relname && relid == get_relname_relid(relname, nspid))
+ {
+ is_clt = true;
+ break;
+ }
+ }
+
+ table_endscan(scan);
+ table_close(rel, AccessShareLock);
+
+ return is_clt;
+}
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 16695592265..eb8cbed6439 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -15,13 +15,28 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/tableam.h"
+#include "access/table.h"
+#include "catalog/indexing.h"
+#include "catalog/namespace.h"
+#include "catalog/pg_namespace_d.h"
+#include "catalog/pg_type.h"
#include "executor/executor.h"
+#include "executor/spi.h"
+#include "funcapi.h"
#include "pgstat.h"
#include "replication/conflict.h"
#include "replication/worker_internal.h"
#include "storage/lmgr.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/lsyscache.h"
+#include "utils/pg_lsn.h"
+#include "utils/array.h"
+#include "utils/jsonb.h"
+
+#define N_LOCAL_CONFLICT_INFO_ATTRS 4
static const char *const ConflictTypeNames[] = {
[CT_INSERT_EXISTS] = "insert_exists",
@@ -50,8 +65,25 @@ static char *build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *localslot,
TupleTableSlot *remoteslot,
Oid indexoid);
+static void build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull);
static char *build_index_value_desc(EState *estate, Relation localrel,
TupleTableSlot *slot, Oid indexoid);
+static Datum tuple_table_slot_to_json_datum(TupleTableSlot *slot);
+static Datum tuple_table_slot_to_ri_json_datum(EState *estate,
+ Relation localrel,
+ Oid replica_index,
+ TupleTableSlot *slot);
+static TupleDesc build_conflict_tupledesc(void);
+static Datum build_local_conflicts_json_array(List *conflicttuples);
+static void prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot);
/*
* Get the xmin and commit timestamp data (origin and timestamp) associated
@@ -106,6 +138,7 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
TupleTableSlot *remoteslot, List *conflicttuples)
{
Relation localrel = relinfo->ri_RelationDesc;
+ Relation conflictlogrel = GetConflictLogTableRel();
StringInfoData err_detail;
initStringInfo(&err_detail);
@@ -120,6 +153,28 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
conflicttuple->ts,
&err_detail);
+ /* Insert conflict details to conflict log table. */
+ if (conflictlogrel)
+ {
+ /*
+ * Prepare the conflict log tuple. If the error level is below ERROR,
+ * insert it immediately. Otherwise, defer the insertion to a new
+ * transaction after the current one aborts, ensuring the insertion of
+ * the log tuple is not rolled back.
+ */
+ prepare_conflict_log_tuple(estate,
+ relinfo->ri_RelationDesc,
+ conflictlogrel,
+ type,
+ searchslot,
+ conflicttuples,
+ remoteslot);
+ if (elevel < ERROR)
+ InsertConflictLogTuple(conflictlogrel);
+
+ table_close(conflictlogrel, RowExclusiveLock);
+ }
+
pgstat_report_subscription_conflict(MySubscription->oid, type);
ereport(elevel,
@@ -162,6 +217,66 @@ InitConflictIndexes(ResultRelInfo *relInfo)
relInfo->ri_onConflictArbiterIndexes = uniqueIndexes;
}
+/*
+ * GetConflictLogTableRel
+ *
+ * Get the information of the specific conflict log table defined in
+ * pg_subscription and opens the relation for insertion. The caller is
+ * responsible for closing the returned relation handle.
+ */
+Relation
+GetConflictLogTableRel(void)
+{
+ Oid nspid;
+ Oid conflictlogrelid;
+ Relation conflictlogrel = NULL;
+ char *conflictlogtable;
+
+ /* If conflict log table is not set for the subscription just return. */
+ conflictlogtable = get_subscription_conflict_log_table(
+ MyLogicalRepWorker->subid, &nspid);
+ if (conflictlogtable == NULL)
+ return NULL;
+
+ conflictlogrelid = get_relname_relid(conflictlogtable, nspid);
+ if (OidIsValid(conflictlogrelid))
+ conflictlogrel = table_open(conflictlogrelid, RowExclusiveLock);
+
+ /* Conflict log table is dropped or not accessible. */
+ if (conflictlogrel == NULL)
+ ereport(WARNING,
+ (errcode(ERRCODE_UNDEFINED_TABLE),
+ errmsg("conflict log table \"%s.%s\" does not exist",
+ get_namespace_name(nspid), conflictlogtable)));
+
+ pfree(conflictlogtable);
+
+ return conflictlogrel;
+}
+
+/*
+ * InsertConflictLogTuple
+ *
+ * Insert conflict log tuple into the conflict log table. It uses
+ * HEAP_INSERT_NO_LOGICAL to explicitly block logical decoding
+ * of the tuple inserted into the conflict log table.
+ */
+void
+InsertConflictLogTuple(Relation conflictlogrel)
+{
+ int options = HEAP_INSERT_NO_LOGICAL;
+
+ /* A valid tuple must be prepared and store in MyLogicalRepWorker. */
+ Assert(MyLogicalRepWorker->conflict_log_tuple != NULL);
+
+ heap_insert(conflictlogrel, MyLogicalRepWorker->conflict_log_tuple,
+ GetCurrentCommandId(true), options, NULL);
+
+ /* Free conflict log tuple. */
+ heap_freetuple(MyLogicalRepWorker->conflict_log_tuple);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+}
+
/*
* Add SQLSTATE error code to the current conflict report.
*/
@@ -472,6 +587,40 @@ build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
return tuple_value.data;
}
+/*
+ * Helper function to extract the "raw" index key Datums and their null flags
+ * from a TupleTableSlot, given an already open index descriptor.
+ * This is the reusable core logic.
+ */
+static void
+build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull)
+{
+ TupleTableSlot *tableslot = slot;
+
+ /*
+ * If the slot is a virtual slot, copy it into a heap tuple slot as
+ * FormIndexDatum only works with heap tuple slots.
+ */
+ if (TTS_IS_VIRTUAL(slot))
+ {
+ /* Slot is created within the EState's tuple table */
+ tableslot = table_slot_create(localrel, &estate->es_tupleTable);
+ tableslot = ExecCopySlot(tableslot, slot);
+ }
+
+ /*
+ * Initialize ecxt_scantuple for potential use in FormIndexDatum
+ */
+ GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+
+ /* Form the index datums */
+ FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values,
+ isnull);
+}
+
/*
* Helper functions to construct a string describing the contents of an index
* entry. See BuildIndexValueDescription for details.
@@ -487,41 +636,300 @@ build_index_value_desc(EState *estate, Relation localrel, TupleTableSlot *slot,
Relation indexDesc;
Datum values[INDEX_MAX_KEYS];
bool isnull[INDEX_MAX_KEYS];
- TupleTableSlot *tableslot = slot;
- if (!tableslot)
+ if (!slot)
return NULL;
Assert(CheckRelationOidLockedByMe(indexoid, RowExclusiveLock, true));
indexDesc = index_open(indexoid, NoLock);
- /*
- * If the slot is a virtual slot, copy it into a heap tuple slot as
- * FormIndexDatum only works with heap tuple slots.
- */
- if (TTS_IS_VIRTUAL(slot))
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+
+ index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+
+ index_close(indexDesc, NoLock);
+
+ return index_value;
+}
+
+/*
+ * tuple_table_slot_to_json_datum
+ *
+ * Helper function to convert a TupleTableSlot to Jsonb.
+ */
+static Datum
+tuple_table_slot_to_json_datum(TupleTableSlot *slot)
+{
+ HeapTuple tuple;
+ Datum datum;
+ Datum json;
+
+ Assert(slot != NULL);
+
+ tuple = ExecCopySlotHeapTuple(slot);
+ datum = heap_copy_tuple_as_datum(tuple, slot->tts_tupleDescriptor);
+
+ json = DirectFunctionCall1(row_to_json, datum);
+ heap_freetuple(tuple);
+
+ return json;
+}
+
+/*
+ * tuple_table_slot_to_ri_json_datum
+ *
+ * Fetch replica identity key from the tuple table slot and convert into a
+ * jsonb datum.
+ */
+static Datum
+tuple_table_slot_to_ri_json_datum(EState *estate, Relation localrel,
+ Oid replica_index, TupleTableSlot *slot)
+{
+ Relation indexDesc;
+ Datum values[INDEX_MAX_KEYS];
+ bool isnull[INDEX_MAX_KEYS];
+ HeapTuple tuple;
+ TupleDesc tupdesc;
+ Datum datum;
+
+ Assert(slot != NULL);
+
+ Assert(CheckRelationOidLockedByMe(replica_index, RowExclusiveLock, true));
+
+ indexDesc = index_open(replica_index, NoLock);
+
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+ tupdesc = RelationGetDescr(indexDesc);
+
+ /* Bless the tupdesc so it can be looked up by row_to_json. */
+ BlessTupleDesc(tupdesc);
+
+ /* Form the replica identity tuple. */
+ tuple = heap_form_tuple(tupdesc, values, isnull);
+ datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ index_close(indexDesc, NoLock);
+
+ /* Convert to a JSONB datum. */
+ return DirectFunctionCall1(row_to_json, datum);
+}
+
+/*
+ * Initialize the tuple descriptor for local conflict info.
+ */
+static TupleDesc
+build_conflict_tupledesc(void)
+{
+ TupleDesc tupdesc;
+
+ tupdesc = CreateTemplateTupleDesc(N_LOCAL_CONFLICT_INFO_ATTRS);
+
+ TupleDescInitEntry(tupdesc, (AttrNumber) 1, "xid",
+ XIDOID, -1, 0);
+ TupleDescInitEntry(tupdesc, (AttrNumber) 2, "commit_ts",
+ TIMESTAMPTZOID, -1, 0);
+ TupleDescInitEntry(tupdesc, (AttrNumber) 3, "origin",
+ TEXTOID, -1, 0);
+ TupleDescInitEntry(tupdesc, (AttrNumber) 4, "tuple",
+ JSONOID, -1, 0);
+
+ BlessTupleDesc(tupdesc);
+
+ return tupdesc;
+}
+
+/*
+ * Builds the local conflicts JSONB array column from the list of
+ * ConflictTupleInfo objects.
+ *
+ * Example output structure:
+ * [ { "xid": "1001", "commit_ts": "...", "origin": "...", "tuple": {...} }, ... ]
+ */
+static Datum
+build_local_conflicts_json_array(List *conflicttuples)
+{
+ ListCell *lc;
+ List *json_datums = NIL; /* List to hold the row_to_json results (type json) */
+ Datum *json_datum_array;
+ bool *json_null_array;
+ Datum json_array_datum;
+ int num_conflicts;
+ int i;
+ int16 typlen;
+ bool typbyval;
+ char typalign;
+ TupleDesc tupdesc;
+
+ /* Build local conflicts tuple descriptor. */
+ tupdesc = build_conflict_tupledesc();
+
+ /* Process local conflict tuple list and prepare a array of JSON. */
+ foreach(lc, conflicttuples)
{
- tableslot = table_slot_create(localrel, &estate->es_tupleTable);
- tableslot = ExecCopySlot(tableslot, slot);
+ ConflictTupleInfo *conflicttuple = (ConflictTupleInfo *) lfirst(lc);
+ Datum values[N_LOCAL_CONFLICT_INFO_ATTRS];
+ bool nulls[N_LOCAL_CONFLICT_INFO_ATTRS];
+ char *origin_name = NULL;
+ HeapTuple tuple;
+ Datum json_datum;
+
+ memset(values, 0, sizeof(Datum) * N_LOCAL_CONFLICT_INFO_ATTRS);
+ memset(nulls, 0, sizeof(bool) * N_LOCAL_CONFLICT_INFO_ATTRS);
+
+ values[0] = TransactionIdGetDatum(conflicttuple->xmin);
+ values[1] = TimestampTzGetDatum(conflicttuple->ts);
+
+ if (conflicttuple->origin != InvalidRepOriginId)
+ replorigin_by_oid(conflicttuple->origin, true, &origin_name);
+
+ /* Store empty string if origin name for the tuple is NULL. */
+ if (origin_name == NULL)
+ origin_name = pstrdup("");
+
+ values[2] = CStringGetTextDatum(origin_name);
+
+ /* Convert conflicting tuple to JSON datum. */
+ values[3] = tuple_table_slot_to_json_datum(conflicttuple->slot);
+
+ tuple = heap_form_tuple(tupdesc, values, nulls);
+
+ json_datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ /*
+ * Build the higher level JSON datum in format described in function
+ * header.
+ */
+ json_datum = DirectFunctionCall1(row_to_json, json_datum);
+
+ /* Done with the temporary tuple. */
+ heap_freetuple(tuple);
+
+ /* Add to the array element. */
+ json_datums = lappend(json_datums, (void *) json_datum);
}
- /*
- * Initialize ecxt_scantuple for potential use in FormIndexDatum when
- * index expressions are present.
- */
- GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+ num_conflicts = list_length(json_datums);
- /*
- * The values/nulls arrays passed to BuildIndexValueDescription should be
- * the results of FormIndexDatum, which are the "raw" input to the index
- * AM.
- */
- FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values, isnull);
+ json_datum_array = (Datum *) palloc(num_conflicts * sizeof(Datum));
+ json_null_array = (bool *) palloc(num_conflicts * sizeof(bool));
- index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+ i = 0;
+ foreach(lc, json_datums)
+ {
+ json_datum_array[i] = (Datum) lfirst(lc);
+ json_null_array[i] = false;
+ i++;
+ }
- index_close(indexDesc, NoLock);
+ get_typlenbyvalalign(JSONOID, &typlen, &typbyval, &typalign);
- return index_value;
+ /* Construct the json[] array Datum */
+ json_array_datum = PointerGetDatum(construct_array(json_datum_array,
+ num_conflicts,
+ JSONOID,
+ typlen,
+ typbyval,
+ typalign));
+ pfree(json_datum_array);
+ pfree(json_null_array);
+
+ return json_array_datum;
+}
+
+/*
+ * prepare_conflict_log_tuple
+ *
+ * This routine prepares a tuple detailing a conflict encountered during
+ * logical replication. The prepared tuple will be stored in
+ * MyLogicalRepWorker->conflict_log_tuple which should be inserted into the
+ * conflict log table by calling InsertConflictLogTuple.
+ */
+static void
+prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot)
+{
+#define MAX_CONFLICT_ATTR_NUM 11
+ Datum values[MAX_CONFLICT_ATTR_NUM];
+ bool nulls[MAX_CONFLICT_ATTR_NUM];
+ int attno;
+ char *remote_origin = NULL;
+ MemoryContext oldctx;
+
+ Assert(MyLogicalRepWorker->conflict_log_tuple == NULL);
+
+ /* Initialize values and nulls arrays. */
+ memset(values, 0, sizeof(Datum) * MAX_CONFLICT_ATTR_NUM);
+ memset(nulls, 0, sizeof(bool) * MAX_CONFLICT_ATTR_NUM);
+
+ /* Populate the values and nulls arrays. */
+ attno = 0;
+ values[attno++] = ObjectIdGetDatum(RelationGetRelid(rel));
+
+ values[attno++] =
+ CStringGetTextDatum(get_namespace_name(RelationGetNamespace(rel)));
+
+ values[attno++] = CStringGetTextDatum(RelationGetRelationName(rel));
+
+ values[attno++] = CStringGetTextDatum(ConflictTypeNames[conflict_type]);
+
+ if (TransactionIdIsValid(remote_xid))
+ values[attno++] = TransactionIdGetDatum(remote_xid);
+ else
+ nulls[attno++] = true;
+
+ values[attno++] = LSNGetDatum(remote_final_lsn);
+
+ if (remote_commit_ts > 0)
+ values[attno++] = TimestampTzGetDatum(remote_commit_ts);
+ else
+ nulls[attno++] = true;
+
+ if (replorigin_session_origin != InvalidRepOriginId)
+ replorigin_by_oid(replorigin_session_origin, true, &remote_origin);
+
+ if (remote_origin != NULL)
+ values[attno++] = CStringGetTextDatum(remote_origin);
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(searchslot))
+ {
+ Oid replica_index = GetRelationIdentityOrPK(rel);
+
+ /*
+ * If the table has a valid replica identity index, build the index
+ * json datum from key value. Otherwise, construct it from the complete
+ * tuple in REPLICA IDENTITY FULL cases.
+ */
+ if (OidIsValid(replica_index))
+ values[attno++] = tuple_table_slot_to_ri_json_datum(estate, rel,
+ replica_index,
+ searchslot);
+ else
+ values[attno++] = tuple_table_slot_to_json_datum(searchslot);
+ }
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(remoteslot))
+ values[attno++] = tuple_table_slot_to_json_datum(remoteslot);
+ else
+ nulls[attno++] = true;
+
+ values[attno] = build_local_conflicts_json_array(conflicttuples);
+
+
+ Assert(attno + 1 == MAX_CONFLICT_ATTR_NUM);
+
+ oldctx = MemoryContextSwitchTo(ApplyContext);
+ MyLogicalRepWorker->conflict_log_tuple =
+ heap_form_tuple(RelationGetDescr(conflictlogrel), values, nulls);
+ MemoryContextSwitchTo(oldctx);
}
diff --git a/src/backend/replication/logical/launcher.c b/src/backend/replication/logical/launcher.c
index fdf1ccad462..2364146ca36 100644
--- a/src/backend/replication/logical/launcher.c
+++ b/src/backend/replication/logical/launcher.c
@@ -477,6 +477,7 @@ retry:
worker->oldest_nonremovable_xid = retain_dead_tuples
? MyReplicationSlot->data.xmin
: InvalidTransactionId;
+ worker->conflict_log_tuple = NULL;
worker->last_lsn = InvalidXLogRecPtr;
TIMESTAMP_NOBEGIN(worker->last_send_time);
TIMESTAMP_NOBEGIN(worker->last_recv_time);
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 93970c6af29..0552c030b45 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -482,7 +482,9 @@ static bool MySubscriptionValid = false;
static List *on_commit_wakeup_workers_subids = NIL;
bool in_remote_transaction = false;
-static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+TransactionId remote_xid = InvalidTransactionId;
+TimestampTz remote_commit_ts = 0;
/* fields valid only when processing streamed transaction */
static bool in_streamed_transaction = false;
@@ -1219,6 +1221,8 @@ apply_handle_begin(StringInfo s)
set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
remote_final_lsn = begin_data.final_lsn;
+ remote_commit_ts = begin_data.committime;
+ remote_xid = begin_data.xid;
maybe_start_skipping_changes(begin_data.final_lsn);
@@ -1745,6 +1749,10 @@ apply_handle_stream_start(StringInfo s)
/* extract XID of the top-level transaction */
stream_xid = logicalrep_read_stream_start(s, &first_segment);
+ remote_xid = stream_xid;
+ remote_final_lsn = InvalidXLogRecPtr;
+ remote_commit_ts = 0;
+
if (!TransactionIdIsValid(stream_xid))
ereport(ERROR,
(errcode(ERRCODE_PROTOCOL_VIOLATION),
@@ -5609,6 +5617,26 @@ start_apply(XLogRecPtr origin_startpos)
pgstat_report_subscription_error(MySubscription->oid,
MyLogicalRepWorker->type);
+ /*
+ * Insert any pending conflict log tuple under a new transaction.
+ */
+ if (MyLogicalRepWorker->conflict_log_tuple != NULL)
+ {
+ Relation conflictlogrel;
+
+ StartTransactionCommand();
+ PushActiveSnapshot(GetTransactionSnapshot());
+
+ /* Open conflict log table and insert the tuple. */
+ conflictlogrel = GetConflictLogTableRel();
+ InsertConflictLogTuple(conflictlogrel);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+ table_close(conflictlogrel, RowExclusiveLock);
+
+ PopActiveSnapshot();
+ CommitTransactionCommand();
+ }
+
PG_RE_THROW();
}
}
diff --git a/src/backend/utils/cache/lsyscache.c b/src/backend/utils/cache/lsyscache.c
index fa7cd7e06a7..12db6676406 100644
--- a/src/backend/utils/cache/lsyscache.c
+++ b/src/backend/utils/cache/lsyscache.c
@@ -3881,3 +3881,41 @@ get_subscription_name(Oid subid, bool missing_ok)
return subname;
}
+
+/*
+ * get_subscription_conflict_log_table
+ *
+ * Get conflict log table name and namespace id from subscription.
+ */
+char *
+get_subscription_conflict_log_table(Oid subid, Oid *nspid)
+{
+ HeapTuple tup;
+ Datum datum;
+ bool isnull;
+ char *relname = NULL;
+ Form_pg_subscription subform;
+
+ *nspid = InvalidOid;
+
+ tup = SearchSysCache1(SUBSCRIPTIONOID, ObjectIdGetDatum(subid));
+
+ if (!HeapTupleIsValid(tup))
+ return NULL;
+
+ subform = (Form_pg_subscription) GETSTRUCT(tup);
+
+ /* Get conflict log table name. */
+ datum = SysCacheGetAttr(SUBSCRIPTIONOID,
+ tup,
+ Anum_pg_subscription_subconflictlogtable,
+ &isnull);
+ if (!isnull)
+ {
+ *nspid = subform->subconflictlognspid;
+ relname = pstrdup(TextDatumGetCString(datum));
+ }
+
+ ReleaseSysCache(tup);
+ return relname;
+}
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index 36f24502842..c18ec248e5d 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -6806,7 +6806,7 @@ describeSubscriptions(const char *pattern, bool verbose)
printQueryOpt myopt = pset.popt;
static const bool translate_columns[] = {false, false, false, false,
false, false, false, false, false, false, false, false, false, false,
- false, false, false, false};
+ false, false, false, false, false};
if (pset.sversion < 100000)
{
@@ -6900,6 +6900,12 @@ describeSubscriptions(const char *pattern, bool verbose)
appendPQExpBuffer(&buf,
", subskiplsn AS \"%s\"\n",
gettext_noop("Skip LSN"));
+
+ /* Conflict log table is only supported in v19 and higher */
+ if (pset.sversion >= 190000)
+ appendPQExpBuffer(&buf,
+ ", subconflictlogtable AS \"%s\"\n",
+ gettext_noop("Conflict log table"));
}
/* Only display subscriptions in current database. */
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 20d7a65c614..6ab4ab8857d 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -2344,8 +2344,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("(", "PUBLICATION");
/* ALTER SUBSCRIPTION <name> SET ( */
else if (Matches("ALTER", "SUBSCRIPTION", MatchAny, MatchAnyN, "SET", "("))
- COMPLETE_WITH("binary", "disable_on_error", "failover",
- "max_retention_duration", "origin",
+ COMPLETE_WITH("binary", "conflict_log_table", "disable_on_error",
+ "failover", "max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
"synchronous_commit", "two_phase");
@@ -3814,8 +3814,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("WITH (");
/* Complete "CREATE SUBSCRIPTION <name> ... WITH ( <opt>" */
else if (Matches("CREATE", "SUBSCRIPTION", MatchAnyN, "WITH", "("))
- COMPLETE_WITH("binary", "connect", "copy_data", "create_slot",
- "disable_on_error", "enabled", "failover",
+ COMPLETE_WITH("binary", "conflict_log_table", "connect", "copy_data",
+ "create_slot", "disable_on_error", "enabled", "failover",
"max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index 55cb9b1eefa..f4526c15ec3 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -80,6 +80,8 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
bool subretaindeadtuples; /* True if dead tuples useful for
* conflict detection are retained */
+ Oid subconflictlognspid; /* Namespace Oid in which the conflict
+ * log table is created. */
int32 submaxretention; /* The maximum duration (in milliseconds)
* for which information useful for
@@ -105,6 +107,9 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
/* Only publish data originating from the specified origin */
text suborigin BKI_DEFAULT(LOGICALREP_ORIGIN_ANY);
+
+ /* Conflict log table name if specified */
+ text subconflictlogtable;
#endif
} FormData_pg_subscription;
diff --git a/src/include/commands/subscriptioncmds.h b/src/include/commands/subscriptioncmds.h
index fb4e26a51a4..6c062b0991f 100644
--- a/src/include/commands/subscriptioncmds.h
+++ b/src/include/commands/subscriptioncmds.h
@@ -36,4 +36,6 @@ extern void CheckSubDeadTupleRetention(bool check_guc, bool sub_disabled,
bool retention_active,
bool max_retention_set);
+extern bool IsConflictLogTable(Oid relid);
+
#endif /* SUBSCRIPTIONCMDS_H */
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index c8fbf9e51b8..ae752015281 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -9,9 +9,11 @@
#ifndef CONFLICT_H
#define CONFLICT_H
+#include "access/htup.h"
#include "access/xlogdefs.h"
#include "nodes/pg_list.h"
#include "utils/timestamp.h"
+#include "utils/relcache.h"
/* Avoid including execnodes.h here */
typedef struct EState EState;
@@ -89,4 +91,6 @@ extern void ReportApplyConflict(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *remoteslot,
List *conflicttuples);
extern void InitConflictIndexes(ResultRelInfo *relInfo);
+extern Relation GetConflictLogTableRel(void);
+extern void InsertConflictLogTuple(Relation conflictlogrel);
#endif
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index f081619f151..711c04c7297 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -101,6 +101,9 @@ typedef struct LogicalRepWorker
*/
TransactionId oldest_nonremovable_xid;
+ /* A conflict log tuple that is prepared but not yet inserted. */
+ HeapTuple conflict_log_tuple;
+
/* Stats. */
XLogRecPtr last_lsn;
TimestampTz last_send_time;
@@ -256,6 +259,10 @@ extern PGDLLIMPORT bool InitializingApplyWorker;
extern PGDLLIMPORT List *table_states_not_ready;
+extern XLogRecPtr remote_final_lsn;
+extern TimestampTz remote_commit_ts;
+extern TransactionId remote_xid;
+
extern void logicalrep_worker_attach(int slot);
extern LogicalRepWorker *logicalrep_worker_find(LogicalRepWorkerType wtype,
Oid subid, Oid relid,
diff --git a/src/include/utils/lsyscache.h b/src/include/utils/lsyscache.h
index 50fb149e9ac..3bebf04bf51 100644
--- a/src/include/utils/lsyscache.h
+++ b/src/include/utils/lsyscache.h
@@ -210,6 +210,7 @@ extern Oid get_publication_oid(const char *pubname, bool missing_ok);
extern char *get_publication_name(Oid pubid, bool missing_ok);
extern Oid get_subscription_oid(const char *subname, bool missing_ok);
extern char *get_subscription_name(Oid subid, bool missing_ok);
+extern char *get_subscription_conflict_log_table(Oid subid, Oid *nspid);
#define type_is_array(typid) (get_element_type(typid) != InvalidOid)
/* type_is_array_domain accepts both plain arrays and domains over arrays */
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index 327d1e7731f..b202a295c06 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -116,18 +116,18 @@ CREATE SUBSCRIPTION regress_testsub4 CONNECTION 'dbname=regress_doesnotexist' PU
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub4 SET (origin = any);
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
DROP SUBSCRIPTION regress_testsub3;
@@ -145,10 +145,10 @@ ALTER SUBSCRIPTION regress_testsub CONNECTION 'foobar';
ERROR: invalid connection string syntax: missing "=" after "foobar" in connection info string
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET PUBLICATION testpub2, testpub3 WITH (refresh = false);
@@ -157,10 +157,10 @@ ALTER SUBSCRIPTION regress_testsub SET (slot_name = 'newname');
ALTER SUBSCRIPTION regress_testsub SET (password_required = false);
ALTER SUBSCRIPTION regress_testsub SET (run_as_owner = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (password_required = true);
@@ -176,10 +176,10 @@ ERROR: unrecognized subscription parameter: "create_slot"
-- ok
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/12345');
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345 |
(1 row)
-- ok - with lsn = NONE
@@ -188,10 +188,10 @@ ALTER SUBSCRIPTION regress_testsub SKIP (lsn = NONE);
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/0');
ERROR: invalid WAL location (LSN): 0/0
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 |
(1 row)
BEGIN;
@@ -223,10 +223,10 @@ ALTER SUBSCRIPTION regress_testsub_foo SET (synchronous_commit = foobar);
ERROR: invalid value for parameter "synchronous_commit": "foobar"
HINT: Available values: local, remote_write, remote_apply, on, off.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
----------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+---------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000 |
(1 row)
-- rename back to keep the rest simple
@@ -255,19 +255,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (binary = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -279,27 +279,27 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = parallel);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- fail - publication already exists
@@ -314,10 +314,10 @@ ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refr
ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refresh = false);
ERROR: publication "testpub1" is already in subscription "regress_testsub"
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- fail - publication used more than once
@@ -332,10 +332,10 @@ ERROR: publication "testpub3" is not in subscription "regress_testsub"
-- ok - delete publications
ALTER SUBSCRIPTION regress_testsub DROP PUBLICATION testpub1, testpub2 WITH (refresh = false);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -371,19 +371,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- we can alter streaming when two_phase enabled
ALTER SUBSCRIPTION regress_testsub SET (streaming = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -393,10 +393,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -409,18 +409,18 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (disable_on_error = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -433,10 +433,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -450,19 +450,19 @@ NOTICE: max_retention_duration is ineffective when retain_dead_tuples is disabl
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- ok
ALTER SUBSCRIPTION regress_testsub SET (max_retention_duration = 0);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -517,7 +517,150 @@ COMMIT;
-- ok, owning it is enough for this stuff
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG TABLE TESTS
+--
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+-- fail - conflict_log_table specified when table already exists
+CREATE TABLE public.regress_conflict_log_temp (id int);
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log_temp');
+ERROR: cannot create conflict log table "public.regress_conflict_log_temp" because a table with that name already exists
+HINT: Use a different name for the conflict log table or drop the existing table.
+DROP TABLE public.regress_conflict_log_temp;
+-- ok - conflict_log_table creation with CREATE SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- check metadata in pg_subscription
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname | subconflictlogtable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test1 | regress_conflict_log1 | t
+(1 row)
+
+-- check if the table exists and has the correct schema (15 columns)
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attnum > 0;
+ count
+-------
+ 11
+(1 row)
+
+-- check a specific column type (e.g., key_tuple should be JSON)
+SELECT format_type(atttypid, atttypmod) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attname = 'key_tuple';
+ format_type
+-------------
+ json
+(1 row)
+
+\dRs+
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+-----------------------
+ regress_conflict_test1 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | regress_conflict_log1
+(1 row)
+
+-- ok - adding conflict_log_table with ALTER SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log2');
+-- check metadata after ALTER
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogtable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test2 | regress_conflict_log2 | t
+(1 row)
+
+-- ok - change the conflict log table name for an existing subscription that already had one
+CREATE SCHEMA clt;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'clt.regress_conflict_log3');
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogtable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test2 | regress_conflict_log3 | f
+(1 row)
+
+\dRs+
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+-----------------------
+ regress_conflict_test1 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | regress_conflict_log1
+ regress_conflict_test2 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | regress_conflict_log3
+(2 rows)
+
+-- check the new table was created and the old table was dropped
+SELECT count(*) FROM pg_class WHERE relname = 'regress_conflict_log2';
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'clt.regress_conflict_log3'::regclass AND attnum > 0;
+ count
+-------
+ 11
+(1 row)
+
+-- ok (NOTICE) - set conflict_log_table to one already used by this subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'clt.regress_conflict_log3');
+NOTICE: "clt.regress_conflict_log3" is already in use as the conflict log table for this subscription
+-- fail - try to publish the conflict_log_table
+CREATE PUBLICATION pub FOR TABLE clt.regress_conflict_log3;
+ERROR: cannot add relation "clt.regress_conflict_log3" to publication
+DETAIL: This operation is not supported for conflict log tables.
+-- suppress warning that depends on wal_level
+SET client_min_messages = 'ERROR';
+-- ok - conflict_log_table should not be published with ALL TABLE
+CREATE PUBLICATION pub FOR TABLES IN SCHEMA clt;
+SELECT * FROM pg_publication_tables WHERE pubname = 'pub';
+ pubname | schemaname | tablename | attnames | rowfilter
+---------+------------+-----------+----------+-----------
+(0 rows)
+
+\dt+ clt.regress_conflict_log3
+ List of tables
+ Schema | Name | Type | Owner | Persistence | Size | Description
+--------+-----------------------+-------+---------------------------+-------------+------------+-------------
+ clt | regress_conflict_log3 | table | regress_subscription_user | permanent | 8192 bytes |
+(1 row)
+
+DROP PUBLICATION pub;
+-- fail - set conflict_log_table to one already used by a different subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log1');
+ERROR: cannot create conflict log table "public.regress_conflict_log1" because a table with that name already exists
+HINT: Use a different name for the conflict log table or drop the existing table.
+-- ok - dropping subscription also drops the log table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the table was dropped
+SELECT to_regclass('public.regress_conflict_log1');
+ to_regclass
+-------------
+
+(1 row)
+
+-- ok - dropping subscription when the log table was manually dropped first
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+DROP TABLE public.regress_conflict_log1;
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the subscription was dropped successfully
+SELECT subname FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname
+---------
+(0 rows)
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
RESET SESSION AUTHORIZATION;
+DROP SCHEMA clt;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
DROP ROLE regress_subscription_user3;
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index ef0c298d2df..49bfb683c57 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -365,7 +365,94 @@ COMMIT;
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG TABLE TESTS
+--
+
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+
+-- fail - conflict_log_table specified when table already exists
+CREATE TABLE public.regress_conflict_log_temp (id int);
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log_temp');
+DROP TABLE public.regress_conflict_log_temp;
+
+-- ok - conflict_log_table creation with CREATE SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+
+-- check metadata in pg_subscription
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- check if the table exists and has the correct schema (15 columns)
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attnum > 0;
+
+-- check a specific column type (e.g., key_tuple should be JSON)
+SELECT format_type(atttypid, atttypmod) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attname = 'key_tuple';
+
+\dRs+
+
+-- ok - adding conflict_log_table with ALTER SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log2');
+
+-- check metadata after ALTER
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- ok - change the conflict log table name for an existing subscription that already had one
+CREATE SCHEMA clt;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'clt.regress_conflict_log3');
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+\dRs+
+
+-- check the new table was created and the old table was dropped
+SELECT count(*) FROM pg_class WHERE relname = 'regress_conflict_log2';
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'clt.regress_conflict_log3'::regclass AND attnum > 0;
+
+-- ok (NOTICE) - set conflict_log_table to one already used by this subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'clt.regress_conflict_log3');
+
+-- fail - try to publish the conflict_log_table
+CREATE PUBLICATION pub FOR TABLE clt.regress_conflict_log3;
+
+-- suppress warning that depends on wal_level
+SET client_min_messages = 'ERROR';
+
+-- ok - conflict_log_table should not be published with ALL TABLE
+CREATE PUBLICATION pub FOR TABLES IN SCHEMA clt;
+SELECT * FROM pg_publication_tables WHERE pubname = 'pub';
+\dt+ clt.regress_conflict_log3
+DROP PUBLICATION pub;
+
+-- fail - set conflict_log_table to one already used by a different subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log1');
+
+-- ok - dropping subscription also drops the log table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the table was dropped
+SELECT to_regclass('public.regress_conflict_log1');
+
+-- ok - dropping subscription when the log table was manually dropped first
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+DROP TABLE public.regress_conflict_log1;
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the subscription was dropped successfully
+SELECT subname FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+
RESET SESSION AUTHORIZATION;
+DROP SCHEMA clt;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
DROP ROLE regress_subscription_user3;
--
2.49.0
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-04 02:00 Masahiko Sawada <sawada.mshk@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
4 siblings, 1 reply; 912+ messages in thread
From: Masahiko Sawada @ 2025-12-04 02:00 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Wed, Dec 3, 2025 at 3:27 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Wed, Dec 3, 2025 at 9:49 AM shveta malik <shveta.malik@gmail.com> wrote:
> > >
> > > relid | 16391
> > > schemaname | public
> > > relname | conf_tab
> > > conflict_type | multiple_unique_conflicts
> > > remote_xid | 761
> > > remote_commit_lsn | 0/01761400
> > > remote_commit_ts | 2025-12-02 15:02:07.045935+00
> > > remote_origin | pg_16406
> > > key_tuple |
> > > remote_tuple | {"a":2,"b":3,"c":4}
> > > local_conflicts |
> > > {"{\"xid\":\"773\",\"commit_ts\":\"2025-12-02T15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":2,\"b\":2,\"c\":2}}","{\"xid\":\"
> > > 773\",\"commit_ts\":\"2025-12-02T15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":3,\"b\":3,\"c\":3}}","{\"xid\":\"773\",\"commit_ts\":\"2025-12-02T
> > > 15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":4,\"b\":4,\"c\":4}}"}
> > >
> >
> > Thanks, it looks good. For the benefit of others, could you include a
> > brief note, perhaps in the commit message for now, describing how to
> > access or read this array column? We can remove it later.
>
> Thanks, okay, temporarily I have added in a commit message how we can
> fetch the data from the JSON array field. In next version I will add
> a test to get the conflict stored in conflict log history table and
> fetch from it.
>
I've reviewed the v9 patch and here are some comments:
The patch utilizes SPI for creating and dropping the conflict history
table, but I'm really not sure if it's okay because it's actually
affected by some GUC parameters such as default_tablespace and
default_toast_compression etc. Also, probably some hooks and event
triggers could be fired during the creation and removal. Is it
intentional behavior? I'm concerned that it would make investigation
harder if an issue happened in the user environment.
---
+ /* build and execute the CREATE TABLE query. */
+ appendStringInfo(&querybuf,
+ "CREATE TABLE %s.%s ("
+ "relid Oid,"
+ "schemaname TEXT,"
+ "relname TEXT,"
+ "conflict_type TEXT,"
+ "remote_xid xid,"
+ "remote_commit_lsn pg_lsn,"
+ "remote_commit_ts TIMESTAMPTZ,"
+ "remote_origin TEXT,"
+ "key_tuple JSON,"
+ "remote_tuple JSON,"
+ "local_conflicts JSON[])",
+ quote_identifier(get_namespace_name(namespaceId)),
+ quote_identifier(conflictrel));
If we want to use SPI for history table creation, we should use
qualified names in all the places including data types.
---
The patch doesn't create the dependency between the subscription and
the conflict history table. So users can entirely drop the schema
(with CASCADE option) where the history table is created. And once
dropping the schema along with the history table, ALTER SUBSCRIPTION
... SET (conflict_history_table = '') seems not to work (I got a
SEGV).
---
We can create the history table in pg_temp namespace but it should not
be allowed.
---
I think the conflict history table should not be transferred to the
new cluster when pg_upgrade since the table definition could be
different across major versions.
I got the following log when the publisher disables track_commit_timestamp:
local_conflicts |
{"{\"xid\":\"790\",\"commit_ts\":\"1999-12-31T16:00:00-08:00\",\"origin\":\"\",\"tuple\":{\"c\":1}}"}
I think we can omit commit_ts when it's omitted.
---
I think we should keep the history table name case-sensitive:
postgres(1:351685)=# create subscription sub connection
'dbname=postgres port=5551' publication pub with (conflict_log_table =
'LOGTABLE');
CREATE SUBSCRIPTION
postgres(1:351685)=# \d
List of relations
Schema | Name | Type | Owner
--------+----------+-------+----------
public | test | table | masahiko
public | logtable | table | masahiko
(2 rows)
Regards,
--
Masahiko Sawada
Amazon Web Services: https://aws.amazon.com
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-04 05:18 Dilip Kumar <dilipbalaut@gmail.com>
parent: Masahiko Sawada <sawada.mshk@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-12-04 05:18 UTC (permalink / raw)
To: Masahiko Sawada <sawada.mshk@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, Dec 4, 2025 at 7:31 AM Masahiko Sawada <sawada.mshk@gmail.com> wrote:
>
> On Wed, Dec 3, 2025 at 3:27 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Wed, Dec 3, 2025 at 9:49 AM shveta malik <shveta.malik@gmail.com> wrote:
> > > >
> > > > relid | 16391
> > > > schemaname | public
> > > > relname | conf_tab
> > > > conflict_type | multiple_unique_conflicts
> > > > remote_xid | 761
> > > > remote_commit_lsn | 0/01761400
> > > > remote_commit_ts | 2025-12-02 15:02:07.045935+00
> > > > remote_origin | pg_16406
> > > > key_tuple |
> > > > remote_tuple | {"a":2,"b":3,"c":4}
> > > > local_conflicts |
> > > > {"{\"xid\":\"773\",\"commit_ts\":\"2025-12-02T15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":2,\"b\":2,\"c\":2}}","{\"xid\":\"
> > > > 773\",\"commit_ts\":\"2025-12-02T15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":3,\"b\":3,\"c\":3}}","{\"xid\":\"773\",\"commit_ts\":\"2025-12-02T
> > > > 15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":4,\"b\":4,\"c\":4}}"}
> > > >
> > >
> > > Thanks, it looks good. For the benefit of others, could you include a
> > > brief note, perhaps in the commit message for now, describing how to
> > > access or read this array column? We can remove it later.
> >
> > Thanks, okay, temporarily I have added in a commit message how we can
> > fetch the data from the JSON array field. In next version I will add
> > a test to get the conflict stored in conflict log history table and
> > fetch from it.
> >
>
> I've reviewed the v9 patch and here are some comments:
Thanks for reviewing this and your valuable comments.
> The patch utilizes SPI for creating and dropping the conflict history
> table, but I'm really not sure if it's okay because it's actually
> affected by some GUC parameters such as default_tablespace and
> default_toast_compression etc. Also, probably some hooks and event
> triggers could be fired during the creation and removal. Is it
> intentional behavior? I'm concerned that it would make investigation
> harder if an issue happened in the user environment.
Hmm, interesting point, well we can control the value of default
parameters while creating the table using SPI, but I don't see any
reason to not use heap_create_with_catalog() directly, so maybe that's
a better choice than using SPI because then we don't need to bother
about any event triggers/utility hooks etc. Although I don't see any
specific issue with that, unless the user intentionally wants to
create trouble while creating this table. What do others think about
it?
> ---
> + /* build and execute the CREATE TABLE query. */
> + appendStringInfo(&querybuf,
> + "CREATE TABLE %s.%s ("
> + "relid Oid,"
> + "schemaname TEXT,"
> + "relname TEXT,"
> + "conflict_type TEXT,"
> + "remote_xid xid,"
> + "remote_commit_lsn pg_lsn,"
> + "remote_commit_ts TIMESTAMPTZ,"
> + "remote_origin TEXT,"
> + "key_tuple JSON,"
> + "remote_tuple JSON,"
> + "local_conflicts JSON[])",
> + quote_identifier(get_namespace_name(namespaceId)),
> + quote_identifier(conflictrel));
>
> If we want to use SPI for history table creation, we should use
> qualified names in all the places including data types.
That's true, so that we can avoid interference of any user created types.
> ---
> The patch doesn't create the dependency between the subscription and
> the conflict history table. So users can entirely drop the schema
> (with CASCADE option) where the history table is created.
I think as part of the initial discussion we thought since it is
created under the subscription owner privileges so only that user can
drop that table and if the user intentionally drops the table the
conflict will not be recorded in the table and that's acceptable. But
now I think it would be a good idea to maintain the dependency with
subscription so that users can not drop it without dropping the
subscription.
And once
> dropping the schema along with the history table, ALTER SUBSCRIPTION
> ... SET (conflict_history_table = '') seems not to work (I got a
> SEGV).
I will check this, thanks
> ---
> We can create the history table in pg_temp namespace but it should not
> be allowed.
Right, will check this and also add the test for the same.
> ---
> I think the conflict history table should not be transferred to the
> new cluster when pg_upgrade since the table definition could be
> different across major versions.
Let me think more on this with respect to behaviour of other factors
like subscriptions etc.
> I got the following log when the publisher disables track_commit_timestamp:
>
> local_conflicts |
> {"{\"xid\":\"790\",\"commit_ts\":\"1999-12-31T16:00:00-08:00\",\"origin\":\"\",\"tuple\":{\"c\":1}}"}
>
> I think we can omit commit_ts when it's omitted.
+1
> ---
> I think we should keep the history table name case-sensitive:
Yeah we can do that, it looks good to me, what do others think about it?
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-04 06:51 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
4 siblings, 0 replies; 912+ messages in thread
From: shveta malik @ 2025-12-04 06:51 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Wed, Dec 3, 2025 at 4:57 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> >
> > Thanks, it looks good. For the benefit of others, could you include a
> > brief note, perhaps in the commit message for now, describing how to
> > access or read this array column? We can remove it later.
>
> Thanks, okay, temporarily I have added in a commit message how we can
> fetch the data from the JSON array field. In next version I will add
> a test to get the conflict stored in conflict log history table and
> fetch from it.
>
Thanks, I have not looked at the patch in detail yet, but a few things:
1)
Assert is hit here:
LOG: logical replication apply worker for subscription "sub1" has started
TRAP: failed Assert("slot != NULL"), File: "conflict.c", Line: 669, PID: 137604
Steps: create table tab1 (i int primary key, j int);
Pub: insert into tab1 values(10,10); insert into tab1 values(20,10);
Sub: delete from tab1 where i=10;
Pub: delete from tab1 where i=10;
2)
I see that key_tuple still points to RI and there is no RI field
added. It seems that discussion at [1] is missed in this patch.
[1]: https://www.postgresql.org/message-id/CAA4eK1L3umixUUik7Ef1eU%3Dx-JMb8iXD7rWWExBMP4dmOGTS9A%40mail.g...
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-04 07:01 Peter Smith <smithpb2250@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
4 siblings, 0 replies; 912+ messages in thread
From: Peter Smith @ 2025-12-04 07:01 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Hi. Some review comments for v9-0001.
======
Commit message.
1.
Note: A single remote tuple may conflict with multiple local conflict
when conflict type
is CT_MULTIPLE_UNIQUE_CONFLICTS, so for handling this case we create a
single row in
conflict log table with respect to each remote conflict row even if it
conflicts with
multiple local rows and we store the multiple conflict tuples as a
single JSON array
element in format as
[ { "xid": "1001", "commit_ts": "...", "origin": "...", "tuple": {...} }, ... ]
We can extract the elements from local tuple as given in below example
~
Something seems broken/confused with this description:
1a.
"A single remote tuple may conflict with multiple local conflict"
Should that say "... with multiple local tuples" ?
~
1b.
There is a mixture of terminology here, "row" vs "tuple", which
doesn't seem correct.
~
1c.
"We can extract the elements from local tuple"
Should that say "... elements of the local tuples from the CLT row ..."
======
src/backend/replication/logical/conflict.c
2.
+
+#define N_LOCAL_CONFLICT_INFO_ATTRS 4
I felt it would be better to put this where it is used. e.g. IMO put
it within the build_conflict_tupledesc().
~~~
InsertConflictLogTuple:
3.
+ /* A valid tuple must be prepared and store in MyLogicalRepWorker. */
Typo still here: /store in/stored in/
~~~
4.
+static TupleDesc
+build_conflict_tupledesc(void)
+{
+ TupleDesc tupdesc;
+
+ tupdesc = CreateTemplateTupleDesc(N_LOCAL_CONFLICT_INFO_ATTRS);
+
+ TupleDescInitEntry(tupdesc, (AttrNumber) 1, "xid",
+ XIDOID, -1, 0);
+ TupleDescInitEntry(tupdesc, (AttrNumber) 2, "commit_ts",
+ TIMESTAMPTZOID, -1, 0);
+ TupleDescInitEntry(tupdesc, (AttrNumber) 3, "origin",
+ TEXTOID, -1, 0);
+ TupleDescInitEntry(tupdesc, (AttrNumber) 4, "tuple",
+ JSONOID, -1, 0);
If you had some incrementing attno instead of hard-wiring the
(1,2,3,4) then you'd be able to add a sanity check like Assert(attno +
1 == N_LOCAL_CONFLICT_INFO_ATTRS); that can safeguard against future
mistakes in case something changes without updating the constant.
~~~
build_local_conflicts_json_array:
5.
+ /* Process local conflict tuple list and prepare a array of JSON. */
+ foreach(lc, conflicttuples)
{
- tableslot = table_slot_create(localrel, &estate->es_tupleTable);
- tableslot = ExecCopySlot(tableslot, slot);
+ ConflictTupleInfo *conflicttuple = (ConflictTupleInfo *) lfirst(lc);
5a.
typo in comment: /a array/an array/
~
5b.
SUGGESTION
foreach_ptr(ConflictTupleInfo, conflicttuple, confrlicttuples)
{
~~~
6.
+ i = 0;
+ foreach(lc, json_datums)
+ {
+ json_datum_array[i] = (Datum) lfirst(lc);
+ json_null_array[i] = false;
+ i++;
+ }
6a.
The loop seemed to be unnecessarily complicated since you already know
the size. Isn't it the same as below?
SUGGESTION
for (int i = 0; i < num_conflicts; i++)
{
json_datum_array[i] = (Datum) list_nth(json_datums, i);
json_null_array[i] = false;
}
6b.
Also, there is probably no need to do json_null_array[i] = false; at
every iteration here, because you could have just used palloc0 for the
whole array in the first place.
======
src/test/regress/expected/subscription.out
7.
+-- check if the table exists and has the correct schema (15 columns)
+SELECT count(*) FROM pg_attribute WHERE attrelid =
'public.regress_conflict_log1'::regclass AND attnum > 0;
+ count
+-------
+ 11
+(1 row)
+
That comment is wrong; there aren't 15 columns anymore.
~~~
8.
(mentioned in a previous review)
I felt that \dRs should display the CLT's schema name in the "Conflict
log table" field -- at least when it's not "public". Otherwise, it
won't be easy for the user to know it.
I did not see a test case for this.
~~~
9.
(mentioned in a previous review)
You could have another test case to explicitly call the function
pg_relation_is_publishable(clt) to verify it returns false for a CTL
table.
======
Kind Regards,
Peter Smith.
Fujitsu Australia
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-04 10:50 Amit Kapila <amit.kapila16@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 2 replies; 912+ messages in thread
From: Amit Kapila @ 2025-12-04 10:50 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Masahiko Sawada <sawada.mshk@gmail.com>; shveta malik <shveta.malik@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, Dec 4, 2025 at 10:49 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Thu, Dec 4, 2025 at 7:31 AM Masahiko Sawada <sawada.mshk@gmail.com> wrote:
> >
>
> > The patch utilizes SPI for creating and dropping the conflict history
> > table, but I'm really not sure if it's okay because it's actually
> > affected by some GUC parameters such as default_tablespace and
> > default_toast_compression etc. Also, probably some hooks and event
> > triggers could be fired during the creation and removal. Is it
> > intentional behavior? I'm concerned that it would make investigation
> > harder if an issue happened in the user environment.
>
> Hmm, interesting point, well we can control the value of default
> parameters while creating the table using SPI, but I don't see any
> reason to not use heap_create_with_catalog() directly, so maybe that's
> a better choice than using SPI because then we don't need to bother
> about any event triggers/utility hooks etc. Although I don't see any
> specific issue with that, unless the user intentionally wants to
> create trouble while creating this table. What do others think about
> it?
>
> > ---
> > + /* build and execute the CREATE TABLE query. */
> > + appendStringInfo(&querybuf,
> > + "CREATE TABLE %s.%s ("
> > + "relid Oid,"
> > + "schemaname TEXT,"
> > + "relname TEXT,"
> > + "conflict_type TEXT,"
> > + "remote_xid xid,"
> > + "remote_commit_lsn pg_lsn,"
> > + "remote_commit_ts TIMESTAMPTZ,"
> > + "remote_origin TEXT,"
> > + "key_tuple JSON,"
> > + "remote_tuple JSON,"
> > + "local_conflicts JSON[])",
> > + quote_identifier(get_namespace_name(namespaceId)),
> > + quote_identifier(conflictrel));
> >
> > If we want to use SPI for history table creation, we should use
> > qualified names in all the places including data types.
>
> That's true, so that we can avoid interference of any user created types.
>
> > ---
> > The patch doesn't create the dependency between the subscription and
> > the conflict history table. So users can entirely drop the schema
> > (with CASCADE option) where the history table is created.
>
> I think as part of the initial discussion we thought since it is
> created under the subscription owner privileges so only that user can
> drop that table and if the user intentionally drops the table the
> conflict will not be recorded in the table and that's acceptable. But
> now I think it would be a good idea to maintain the dependency with
> subscription so that users can not drop it without dropping the
> subscription.
>
Yeah, it seems reasonable to maintain its dependency with the
subscription in this model. BTW, for this it would be easier to record
dependency, if we use heap_create_with_catalog() as we do for
create_toast_table(). The other places where we use SPI interface to
execute statements are either the places where we need to execute
multiple SQL statements or non-CREATE Table statements. So, for this
patch's purpose, I feel heap_create_with_catalog() suits more.
I was also thinking whether it is a good idea to create one global
conflict table and let all subscriptions use it. However, it has
disadvantages like whenever, user drops any subscription, we need to
DELETE all conflict rows for that subscription causing the need for
vacuum. Then we somehow need to ensure that conflicts from one
subscription_owner are not visible to other subscription_owner via
some RLS policy. So, catalog table per-subscription (aka) the current
way appears better.
Also, shall we give the option to the user where she wants to see
conflict/resolution information? One idea to achieve the same is to
provide subscription options like (a) conflict_resolution_format, the
values could be log and table for now, in future, one could extend it
to other options like xml, json, etc. (b) conflict_log_table: in this
user can specify the conflict table name, this can be optional such
that if user omits this and conflict_resolution_format is table, then
we will use internally generated table name like
pg_conflicts_<subscription_id>.
> And once
> > dropping the schema along with the history table, ALTER SUBSCRIPTION
> > ... SET (conflict_history_table = '') seems not to work (I got a
> > SEGV).
>
> I will check this, thanks
>
> > ---
> > We can create the history table in pg_temp namespace but it should not
> > be allowed.
>
> Right, will check this and also add the test for the same.
>
> > ---
> > I think the conflict history table should not be transferred to the
> > new cluster when pg_upgrade since the table definition could be
> > different across major versions.
>
> Let me think more on this with respect to behaviour of other factors
> like subscriptions etc.
>
Can we deal with different schema of tables across versions via
pg_dump/restore during upgrade?
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-04 14:35 vignesh C <vignesh21@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
4 siblings, 1 reply; 912+ messages in thread
From: vignesh C @ 2025-12-04 14:35 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Wed, 3 Dec 2025 at 16:57, Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Wed, Dec 3, 2025 at 9:49 AM shveta malik <shveta.malik@gmail.com> wrote:
> > >
> > > relid | 16391
> > > schemaname | public
> > > relname | conf_tab
> > > conflict_type | multiple_unique_conflicts
> > > remote_xid | 761
> > > remote_commit_lsn | 0/01761400
> > > remote_commit_ts | 2025-12-02 15:02:07.045935+00
> > > remote_origin | pg_16406
> > > key_tuple |
> > > remote_tuple | {"a":2,"b":3,"c":4}
> > > local_conflicts |
> > > {"{\"xid\":\"773\",\"commit_ts\":\"2025-12-02T15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":2,\"b\":2,\"c\":2}}","{\"xid\":\"
> > > 773\",\"commit_ts\":\"2025-12-02T15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":3,\"b\":3,\"c\":3}}","{\"xid\":\"773\",\"commit_ts\":\"2025-12-02T
> > > 15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":4,\"b\":4,\"c\":4}}"}
> > >
> >
> > Thanks, it looks good. For the benefit of others, could you include a
> > brief note, perhaps in the commit message for now, describing how to
> > access or read this array column? We can remove it later.
>
> Thanks, okay, temporarily I have added in a commit message how we can
> fetch the data from the JSON array field. In next version I will add
> a test to get the conflict stored in conflict log history table and
> fetch from it.
I noticed that the table structure can get changed by the time the
conflict record is prepared. In ReportApplyConflict(), the code
currently prepares the conflict log tuple before deciding whether the
insertion will be immediate or deferred:
+ /* Insert conflict details to conflict log table. */
+ if (conflictlogrel)
+ {
+ /*
+ * Prepare the conflict log tuple. If the error level
is below ERROR,
+ * insert it immediately. Otherwise, defer the
insertion to a new
+ * transaction after the current one aborts, ensuring
the insertion of
+ * the log tuple is not rolled back.
+ */
+ prepare_conflict_log_tuple(estate,
+
relinfo->ri_RelationDesc,
+
conflictlogrel,
+ type,
+ searchslot,
+
conflicttuples,
+ remoteslot);
+ if (elevel < ERROR)
+ InsertConflictLogTuple(conflictlogrel);
+
+ table_close(conflictlogrel, RowExclusiveLock);
+ }
If the conflict history table defintion is changed just before
prepare_conflict_log_tuple, the tuple creation will crash:
Program received signal SIGSEGV, Segmentation fault.
0x00005a342e01df4f in VARATT_CAN_MAKE_SHORT (PTR=0x4000) at
../../../../src/include/varatt.h:419
419 return VARATT_IS_4B_U(PTR) &&
(gdb) bt
#0 0x00005a342e01df4f in VARATT_CAN_MAKE_SHORT (PTR=0x4000) at
../../../../src/include/varatt.h:419
#1 0x00005a342e01e5ed in heap_compute_data_size
(tupleDesc=0x7ab405e5dda8, values=0x7ffd7af3ad20,
isnull=0x7ffd7af3ad15) at heaptuple.c:239
#2 0x00005a342e0200dd in heap_form_tuple
(tupleDescriptor=0x7ab405e5dda8, values=0x7ffd7af3ad20,
isnull=0x7ffd7af3ad15) at heaptuple.c:1158
#3 0x00005a342e55e8c2 in prepare_conflict_log_tuple
(estate=0x5a3467944530, rel=0x7ab405e594e8,
conflictlogrel=0x7ab405e5da88, conflict_type=CT_INSERT_EXISTS,
searchslot=0x0,
conflicttuples=0x5a3467942da0, remoteslot=0x5a346792e498) at conflict.c:936
#4 0x00005a342e55cea6 in ReportApplyConflict (estate=0x5a3467944530,
relinfo=0x5a346792e778, elevel=21, type=CT_INSERT_EXISTS,
searchslot=0x0, remoteslot=0x5a346792e498,
conflicttuples=0x5a3467942da0) at conflict.c:168
#5 0x00005a342e348c35 in CheckAndReportConflict
(resultRelInfo=0x5a346792e778, estate=0x5a3467944530,
type=CT_INSERT_EXISTS, recheckIndexes=0x5a3467942648, searchslot=0x0,
remoteslot=0x5a346792e498) at execReplication.c:793
This can be reproduced by the following steps:
CREATE PUBLICATION pub;
CREATE SUBSCRIPTION sub ... WITH (conflict_log_table = 'conflict');
ALTER TABLE conflict RENAME TO conflict1:
CREATE TABLE conflict(c1 varchar, c2 varchar);
-- Cause a conflict, this will crash while trying to prepare the
conflicting tuple
Regards,
Vignesh
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-05 03:54 vignesh C <vignesh21@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
4 siblings, 1 reply; 912+ messages in thread
From: vignesh C @ 2025-12-05 03:54 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Wed, 3 Dec 2025 at 16:57, Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Wed, Dec 3, 2025 at 9:49 AM shveta malik <shveta.malik@gmail.com> wrote:
> > >
> > > relid | 16391
> > > schemaname | public
> > > relname | conf_tab
> > > conflict_type | multiple_unique_conflicts
> > > remote_xid | 761
> > > remote_commit_lsn | 0/01761400
> > > remote_commit_ts | 2025-12-02 15:02:07.045935+00
> > > remote_origin | pg_16406
> > > key_tuple |
> > > remote_tuple | {"a":2,"b":3,"c":4}
> > > local_conflicts |
> > > {"{\"xid\":\"773\",\"commit_ts\":\"2025-12-02T15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":2,\"b\":2,\"c\":2}}","{\"xid\":\"
> > > 773\",\"commit_ts\":\"2025-12-02T15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":3,\"b\":3,\"c\":3}}","{\"xid\":\"773\",\"commit_ts\":\"2025-12-02T
> > > 15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":4,\"b\":4,\"c\":4}}"}
> > >
> >
> > Thanks, it looks good. For the benefit of others, could you include a
> > brief note, perhaps in the commit message for now, describing how to
> > access or read this array column? We can remove it later.
>
> Thanks, okay, temporarily I have added in a commit message how we can
> fetch the data from the JSON array field. In next version I will add
> a test to get the conflict stored in conflict log history table and
> fetch from it.
Few comments:
1) Currently pg_dump is not dumping conflict_log_table option, I felt
it should be included while dumping.
2) Is there a way to unset the conflict log table after we create the
subscription with conflict_log_table option
3) Any reason why this table should not be allowed to add to a publication:
+ /* Can't be conflict log table */
+ if (IsConflictLogTable(RelationGetRelid(targetrel)))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot add relation \"%s.%s\"
to publication",
+
get_namespace_name(RelationGetNamespace(targetrel)),
+
RelationGetRelationName(targetrel)),
+ errdetail("This operation is not
supported for conflict log tables.")));
Is the reason like the same table can be a conflict table in the
subscriber and prevent corruption in the subscriber
4) I did not find any documentation for this feature, can we include
documentation in create_subscription.sgml, alter_subscription.sgml and
logical_replication.sgml
Regards,
Vignesh
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-05 05:09 Dilip Kumar <dilipbalaut@gmail.com>
parent: vignesh C <vignesh21@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-12-05 05:09 UTC (permalink / raw)
To: vignesh C <vignesh21@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, Dec 4, 2025 at 8:05 PM vignesh C <vignesh21@gmail.com> wrote:
>
> On Wed, 3 Dec 2025 at 16:57, Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Wed, Dec 3, 2025 at 9:49 AM shveta malik <shveta.malik@gmail.com> wrote:
> > > >
> > > > relid | 16391
> > > > schemaname | public
> > > > relname | conf_tab
> > > > conflict_type | multiple_unique_conflicts
> > > > remote_xid | 761
> > > > remote_commit_lsn | 0/01761400
> > > > remote_commit_ts | 2025-12-02 15:02:07.045935+00
> > > > remote_origin | pg_16406
> > > > key_tuple |
> > > > remote_tuple | {"a":2,"b":3,"c":4}
> > > > local_conflicts |
> > > > {"{\"xid\":\"773\",\"commit_ts\":\"2025-12-02T15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":2,\"b\":2,\"c\":2}}","{\"xid\":\"
> > > > 773\",\"commit_ts\":\"2025-12-02T15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":3,\"b\":3,\"c\":3}}","{\"xid\":\"773\",\"commit_ts\":\"2025-12-02T
> > > > 15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":4,\"b\":4,\"c\":4}}"}
> > > >
> > >
> > > Thanks, it looks good. For the benefit of others, could you include a
> > > brief note, perhaps in the commit message for now, describing how to
> > > access or read this array column? We can remove it later.
> >
> > Thanks, okay, temporarily I have added in a commit message how we can
> > fetch the data from the JSON array field. In next version I will add
> > a test to get the conflict stored in conflict log history table and
> > fetch from it.
>
> I noticed that the table structure can get changed by the time the
> conflict record is prepared. In ReportApplyConflict(), the code
> currently prepares the conflict log tuple before deciding whether the
> insertion will be immediate or deferred:
> + /* Insert conflict details to conflict log table. */
> + if (conflictlogrel)
> + {
> + /*
> + * Prepare the conflict log tuple. If the error level
> is below ERROR,
> + * insert it immediately. Otherwise, defer the
> insertion to a new
> + * transaction after the current one aborts, ensuring
> the insertion of
> + * the log tuple is not rolled back.
> + */
> + prepare_conflict_log_tuple(estate,
> +
> relinfo->ri_RelationDesc,
> +
> conflictlogrel,
> + type,
> + searchslot,
> +
> conflicttuples,
> + remoteslot);
> + if (elevel < ERROR)
> + InsertConflictLogTuple(conflictlogrel);
> +
> + table_close(conflictlogrel, RowExclusiveLock);
> + }
>
> If the conflict history table defintion is changed just before
> prepare_conflict_log_tuple, the tuple creation will crash:
> Program received signal SIGSEGV, Segmentation fault.
> 0x00005a342e01df4f in VARATT_CAN_MAKE_SHORT (PTR=0x4000) at
> ../../../../src/include/varatt.h:419
> 419 return VARATT_IS_4B_U(PTR) &&
> (gdb) bt
> #0 0x00005a342e01df4f in VARATT_CAN_MAKE_SHORT (PTR=0x4000) at
> ../../../../src/include/varatt.h:419
> #1 0x00005a342e01e5ed in heap_compute_data_size
> (tupleDesc=0x7ab405e5dda8, values=0x7ffd7af3ad20,
> isnull=0x7ffd7af3ad15) at heaptuple.c:239
> #2 0x00005a342e0200dd in heap_form_tuple
> (tupleDescriptor=0x7ab405e5dda8, values=0x7ffd7af3ad20,
> isnull=0x7ffd7af3ad15) at heaptuple.c:1158
> #3 0x00005a342e55e8c2 in prepare_conflict_log_tuple
> (estate=0x5a3467944530, rel=0x7ab405e594e8,
> conflictlogrel=0x7ab405e5da88, conflict_type=CT_INSERT_EXISTS,
> searchslot=0x0,
> conflicttuples=0x5a3467942da0, remoteslot=0x5a346792e498) at conflict.c:936
> #4 0x00005a342e55cea6 in ReportApplyConflict (estate=0x5a3467944530,
> relinfo=0x5a346792e778, elevel=21, type=CT_INSERT_EXISTS,
> searchslot=0x0, remoteslot=0x5a346792e498,
> conflicttuples=0x5a3467942da0) at conflict.c:168
> #5 0x00005a342e348c35 in CheckAndReportConflict
> (resultRelInfo=0x5a346792e778, estate=0x5a3467944530,
> type=CT_INSERT_EXISTS, recheckIndexes=0x5a3467942648, searchslot=0x0,
> remoteslot=0x5a346792e498) at execReplication.c:793
>
> This can be reproduced by the following steps:
> CREATE PUBLICATION pub;
> CREATE SUBSCRIPTION sub ... WITH (conflict_log_table = 'conflict');
> ALTER TABLE conflict RENAME TO conflict1:
> CREATE TABLE conflict(c1 varchar, c2 varchar);
> -- Cause a conflict, this will crash while trying to prepare the
> conflicting tuple
Yeah while it is allowed to drop or alter the conflict log table, it
should not seg fault, IMHO error is acceptable as per the initial
discussion, so I will look into this and tighten up the logic so that
it will throw an error whenever it can not insert into the conflict
log table.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-05 05:16 Dilip Kumar <dilipbalaut@gmail.com>
parent: vignesh C <vignesh21@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-12-05 05:16 UTC (permalink / raw)
To: vignesh C <vignesh21@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Fri, Dec 5, 2025 at 9:24 AM vignesh C <vignesh21@gmail.com> wrote:
>
> On Wed, 3 Dec 2025 at 16:57, Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Wed, Dec 3, 2025 at 9:49 AM shveta malik <shveta.malik@gmail.com> wrote:
> > > >
> > > > relid | 16391
> > > > schemaname | public
> > > > relname | conf_tab
> > > > conflict_type | multiple_unique_conflicts
> > > > remote_xid | 761
> > > > remote_commit_lsn | 0/01761400
> > > > remote_commit_ts | 2025-12-02 15:02:07.045935+00
> > > > remote_origin | pg_16406
> > > > key_tuple |
> > > > remote_tuple | {"a":2,"b":3,"c":4}
> > > > local_conflicts |
> > > > {"{\"xid\":\"773\",\"commit_ts\":\"2025-12-02T15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":2,\"b\":2,\"c\":2}}","{\"xid\":\"
> > > > 773\",\"commit_ts\":\"2025-12-02T15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":3,\"b\":3,\"c\":3}}","{\"xid\":\"773\",\"commit_ts\":\"2025-12-02T
> > > > 15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":4,\"b\":4,\"c\":4}}"}
> > > >
> > >
> > > Thanks, it looks good. For the benefit of others, could you include a
> > > brief note, perhaps in the commit message for now, describing how to
> > > access or read this array column? We can remove it later.
> >
> > Thanks, okay, temporarily I have added in a commit message how we can
> > fetch the data from the JSON array field. In next version I will add
> > a test to get the conflict stored in conflict log history table and
> > fetch from it.
>
> Few comments:
> 1) Currently pg_dump is not dumping conflict_log_table option, I felt
> it should be included while dumping.
Yeah, we should.
> 2) Is there a way to unset the conflict log table after we create the
> subscription with conflict_log_table option
IMHO we can use ALTER SUBSCRIPTION...WITH(conflict_log_table='') so
unset? What do others think about it?
> 3) Any reason why this table should not be allowed to add to a publication:
> + /* Can't be conflict log table */
> + if (IsConflictLogTable(RelationGetRelid(targetrel)))
> + ereport(ERROR,
> + (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
> + errmsg("cannot add relation \"%s.%s\"
> to publication",
> +
> get_namespace_name(RelationGetNamespace(targetrel)),
> +
> RelationGetRelationName(targetrel)),
> + errdetail("This operation is not
> supported for conflict log tables.")));
>
> Is the reason like the same table can be a conflict table in the
> subscriber and prevent corruption in the subscriber
The main reason was that, since these tables are internally created
for maintaining the conflict information which is very much internal
node specific details, so there is no reason someone want to replicate
those tables, so we blocked it with ALL TABLES option and then based
on suggestion from Shveta we blocked it from getting added to
publication as well. So there is no strong reason to disallow from
forcefully getting added to publication OTOH there is no reason why
someone wants to do that considering those are internally managed
tables.
> 4) I did not find any documentation for this feature, can we include
> documentation in create_subscription.sgml, alter_subscription.sgml and
> logical_replication.sgml
Yeah, in the initial version I posted a doc patch, but since we are
doing changes in the first patch and also some behavior might change
so I will postpone it for a later stage after we have consensus on
most of the behaviour.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-05 09:29 Amit Kapila <amit.kapila16@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 0 replies; 912+ messages in thread
From: Amit Kapila @ 2025-12-05 09:29 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Fri, Dec 5, 2025 at 10:47 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Fri, Dec 5, 2025 at 9:24 AM vignesh C <vignesh21@gmail.com> wrote:
> >
>
> > 2) Is there a way to unset the conflict log table after we create the
> > subscription with conflict_log_table option
>
> IMHO we can use ALTER SUBSCRIPTION...WITH(conflict_log_table='') so
> unset? What do others think about it?
>
We already have a syntax: ALTER SUBSCRIPTION name SET (
subscription_parameter [= value] [, ... ] ) which can be used to
set/unset this new subscription option.
> > 3) Any reason why this table should not be allowed to add to a publication:
> > + /* Can't be conflict log table */
> > + if (IsConflictLogTable(RelationGetRelid(targetrel)))
> > + ereport(ERROR,
> > + (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
> > + errmsg("cannot add relation \"%s.%s\"
> > to publication",
> > +
> > get_namespace_name(RelationGetNamespace(targetrel)),
> > +
> > RelationGetRelationName(targetrel)),
> > + errdetail("This operation is not
> > supported for conflict log tables.")));
> >
> > Is the reason like the same table can be a conflict table in the
> > subscriber and prevent corruption in the subscriber
>
> The main reason was that, since these tables are internally created
> for maintaining the conflict information which is very much internal
> node specific details, so there is no reason someone want to replicate
> those tables, so we blocked it with ALL TABLES option and then based
> on suggestion from Shveta we blocked it from getting added to
> publication as well. So there is no strong reason to disallow from
> forcefully getting added to publication OTOH there is no reason why
> someone wants to do that considering those are internally managed
> tables.
>
I also don't see any reason to allow such internal tables to be
replicated. So, it is okay to prohibit them for now. If we see any use
case, we can allow it.
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-05 09:55 Amit Kapila <amit.kapila16@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
1 sibling, 2 replies; 912+ messages in thread
From: Amit Kapila @ 2025-12-05 09:55 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Masahiko Sawada <sawada.mshk@gmail.com>; shveta malik <shveta.malik@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, Dec 4, 2025 at 4:20 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> Also, shall we give the option to the user where she wants to see
> conflict/resolution information? One idea to achieve the same is to
> provide subscription options like (a) conflict_resolution_format, the
> values could be log and table for now, in future, one could extend it
> to other options like xml, json, etc. (b) conflict_log_table: in this
> user can specify the conflict table name, this can be optional such
> that if user omits this and conflict_resolution_format is table, then
> we will use internally generated table name like
> pg_conflicts_<subscription_id>.
>
In this idea, we can keep the name of the second option as
conflict_log_name instead of conflict_log_table. This can help us LOG
the conflicts in a totally separate conflict file instead of in server
log. Say, the user provides conflict_resolution_format as 'log' and
conflict_log_name as 'conflict_report' then we can report conflicts in
this separate file by appending subid to distinguish it. And, if the
user gives only the first option conflict_resolution_format as 'log'
then we can keep reporting the information in server log files.
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-05 10:13 shveta malik <shveta.malik@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
1 sibling, 0 replies; 912+ messages in thread
From: shveta malik @ 2025-12-05 10:13 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: Dilip Kumar <dilipbalaut@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Fri, Dec 5, 2025 at 3:25 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Thu, Dec 4, 2025 at 4:20 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> >
> > Also, shall we give the option to the user where she wants to see
> > conflict/resolution information? One idea to achieve the same is to
> > provide subscription options like (a) conflict_resolution_format, the
> > values could be log and table for now, in future, one could extend it
> > to other options like xml, json, etc. (b) conflict_log_table: in this
> > user can specify the conflict table name, this can be optional such
> > that if user omits this and conflict_resolution_format is table, then
> > we will use internally generated table name like
> > pg_conflicts_<subscription_id>.
> >
>
> In this idea, we can keep the name of the second option as
> conflict_log_name instead of conflict_log_table. This can help us LOG
> the conflicts in a totally separate conflict file instead of in server
> log. Say, the user provides conflict_resolution_format as 'log' and
> conflict_log_name as 'conflict_report' then we can report conflicts in
> this separate file by appending subid to distinguish it. And, if the
> user gives only the first option conflict_resolution_format as 'log'
> then we can keep reporting the information in server log files.
>
+1 on the idea.
Instead of using conflict_resolution_format, I feel it should be
conflict_log_format as we are referring to LOGs and not resolutions.
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-06 04:50 Dilip Kumar <dilipbalaut@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
1 sibling, 0 replies; 912+ messages in thread
From: Dilip Kumar @ 2025-12-06 04:50 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: Masahiko Sawada <sawada.mshk@gmail.com>; shveta malik <shveta.malik@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Fri, Dec 5, 2025 at 3:25 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Thu, Dec 4, 2025 at 4:20 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> >
> > Also, shall we give the option to the user where she wants to see
> > conflict/resolution information? One idea to achieve the same is to
> > provide subscription options like (a) conflict_resolution_format, the
> > values could be log and table for now, in future, one could extend it
> > to other options like xml, json, etc. (b) conflict_log_table: in this
> > user can specify the conflict table name, this can be optional such
> > that if user omits this and conflict_resolution_format is table, then
> > we will use internally generated table name like
> > pg_conflicts_<subscription_id>.
> >
>
> In this idea, we can keep the name of the second option as
> conflict_log_name instead of conflict_log_table. This can help us LOG
> the conflicts in a totally separate conflict file instead of in server
> log. Say, the user provides conflict_resolution_format as 'log' and
> conflict_log_name as 'conflict_report' then we can report conflicts in
> this separate file by appending subid to distinguish it. And, if the
> user gives only the first option conflict_resolution_format as 'log'
> then we can keep reporting the information in server log files.
Yeah that looks good, so considering the extensibility I think we can
keep the option name as 'conflict_log_name' from the first version
itself even if we don't provide all the options in the first version.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-06 15:06 Dilip Kumar <dilipbalaut@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-12-06 15:06 UTC (permalink / raw)
To: vignesh C <vignesh21@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Fri, Dec 5, 2025 at 10:39 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Thu, Dec 4, 2025 at 8:05 PM vignesh C <vignesh21@gmail.com> wrote:
> >
> > On Wed, 3 Dec 2025 at 16:57, Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > On Wed, Dec 3, 2025 at 9:49 AM shveta malik <shveta.malik@gmail.com> wrote:
> > > > >
> > > > > relid | 16391
> > > > > schemaname | public
> > > > > relname | conf_tab
> > > > > conflict_type | multiple_unique_conflicts
> > > > > remote_xid | 761
> > > > > remote_commit_lsn | 0/01761400
> > > > > remote_commit_ts | 2025-12-02 15:02:07.045935+00
> > > > > remote_origin | pg_16406
> > > > > key_tuple |
> > > > > remote_tuple | {"a":2,"b":3,"c":4}
> > > > > local_conflicts |
> > > > > {"{\"xid\":\"773\",\"commit_ts\":\"2025-12-02T15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":2,\"b\":2,\"c\":2}}","{\"xid\":\"
> > > > > 773\",\"commit_ts\":\"2025-12-02T15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":3,\"b\":3,\"c\":3}}","{\"xid\":\"773\",\"commit_ts\":\"2025-12-02T
> > > > > 15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":4,\"b\":4,\"c\":4}}"}
> > > > >
> > > >
> > > > Thanks, it looks good. For the benefit of others, could you include a
> > > > brief note, perhaps in the commit message for now, describing how to
> > > > access or read this array column? We can remove it later.
> > >
> > > Thanks, okay, temporarily I have added in a commit message how we can
> > > fetch the data from the JSON array field. In next version I will add
> > > a test to get the conflict stored in conflict log history table and
> > > fetch from it.
> >
> > I noticed that the table structure can get changed by the time the
> > conflict record is prepared. In ReportApplyConflict(), the code
> > currently prepares the conflict log tuple before deciding whether the
> > insertion will be immediate or deferred:
> > + /* Insert conflict details to conflict log table. */
> > + if (conflictlogrel)
> > + {
> > + /*
> > + * Prepare the conflict log tuple. If the error level
> > is below ERROR,
> > + * insert it immediately. Otherwise, defer the
> > insertion to a new
> > + * transaction after the current one aborts, ensuring
> > the insertion of
> > + * the log tuple is not rolled back.
> > + */
> > + prepare_conflict_log_tuple(estate,
> > +
> > relinfo->ri_RelationDesc,
> > +
> > conflictlogrel,
> > + type,
> > + searchslot,
> > +
> > conflicttuples,
> > + remoteslot);
> > + if (elevel < ERROR)
> > + InsertConflictLogTuple(conflictlogrel);
> > +
> > + table_close(conflictlogrel, RowExclusiveLock);
> > + }
> >
> > If the conflict history table defintion is changed just before
> > prepare_conflict_log_tuple, the tuple creation will crash:
> > Program received signal SIGSEGV, Segmentation fault.
> > 0x00005a342e01df4f in VARATT_CAN_MAKE_SHORT (PTR=0x4000) at
> > ../../../../src/include/varatt.h:419
> > 419 return VARATT_IS_4B_U(PTR) &&
> > (gdb) bt
> > #0 0x00005a342e01df4f in VARATT_CAN_MAKE_SHORT (PTR=0x4000) at
> > ../../../../src/include/varatt.h:419
> > #1 0x00005a342e01e5ed in heap_compute_data_size
> > (tupleDesc=0x7ab405e5dda8, values=0x7ffd7af3ad20,
> > isnull=0x7ffd7af3ad15) at heaptuple.c:239
> > #2 0x00005a342e0200dd in heap_form_tuple
> > (tupleDescriptor=0x7ab405e5dda8, values=0x7ffd7af3ad20,
> > isnull=0x7ffd7af3ad15) at heaptuple.c:1158
> > #3 0x00005a342e55e8c2 in prepare_conflict_log_tuple
> > (estate=0x5a3467944530, rel=0x7ab405e594e8,
> > conflictlogrel=0x7ab405e5da88, conflict_type=CT_INSERT_EXISTS,
> > searchslot=0x0,
> > conflicttuples=0x5a3467942da0, remoteslot=0x5a346792e498) at conflict.c:936
> > #4 0x00005a342e55cea6 in ReportApplyConflict (estate=0x5a3467944530,
> > relinfo=0x5a346792e778, elevel=21, type=CT_INSERT_EXISTS,
> > searchslot=0x0, remoteslot=0x5a346792e498,
> > conflicttuples=0x5a3467942da0) at conflict.c:168
> > #5 0x00005a342e348c35 in CheckAndReportConflict
> > (resultRelInfo=0x5a346792e778, estate=0x5a3467944530,
> > type=CT_INSERT_EXISTS, recheckIndexes=0x5a3467942648, searchslot=0x0,
> > remoteslot=0x5a346792e498) at execReplication.c:793
> >
> > This can be reproduced by the following steps:
> > CREATE PUBLICATION pub;
> > CREATE SUBSCRIPTION sub ... WITH (conflict_log_table = 'conflict');
> > ALTER TABLE conflict RENAME TO conflict1:
> > CREATE TABLE conflict(c1 varchar, c2 varchar);
> > -- Cause a conflict, this will crash while trying to prepare the
> > conflicting tuple
>
> Yeah while it is allowed to drop or alter the conflict log table, it
> should not seg fault, IMHO error is acceptable as per the initial
> discussion, so I will look into this and tighten up the logic so that
> it will throw an error whenever it can not insert into the conflict
> log table.
I was thinking about the solution that we need to do if table
definition is changed, one option is whenever we try to prepare the
tuple after acquiring the lock we can validate the table definition if
this doesn't qualify the standard conflict log table schema we can
ERROR out. IMHO that should not be an issue as we are only doing this
in conflict logging.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-08 03:42 vignesh C <vignesh21@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: vignesh C @ 2025-12-08 03:42 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Sat, 6 Dec 2025 at 20:36, Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Fri, Dec 5, 2025 at 10:39 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Thu, Dec 4, 2025 at 8:05 PM vignesh C <vignesh21@gmail.com> wrote:
> > >
> > > On Wed, 3 Dec 2025 at 16:57, Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > >
> > > > On Wed, Dec 3, 2025 at 9:49 AM shveta malik <shveta.malik@gmail.com> wrote:
> > > > > >
> > > > > > relid | 16391
> > > > > > schemaname | public
> > > > > > relname | conf_tab
> > > > > > conflict_type | multiple_unique_conflicts
> > > > > > remote_xid | 761
> > > > > > remote_commit_lsn | 0/01761400
> > > > > > remote_commit_ts | 2025-12-02 15:02:07.045935+00
> > > > > > remote_origin | pg_16406
> > > > > > key_tuple |
> > > > > > remote_tuple | {"a":2,"b":3,"c":4}
> > > > > > local_conflicts |
> > > > > > {"{\"xid\":\"773\",\"commit_ts\":\"2025-12-02T15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":2,\"b\":2,\"c\":2}}","{\"xid\":\"
> > > > > > 773\",\"commit_ts\":\"2025-12-02T15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":3,\"b\":3,\"c\":3}}","{\"xid\":\"773\",\"commit_ts\":\"2025-12-02T
> > > > > > 15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":4,\"b\":4,\"c\":4}}"}
> > > > > >
> > > > >
> > > > > Thanks, it looks good. For the benefit of others, could you include a
> > > > > brief note, perhaps in the commit message for now, describing how to
> > > > > access or read this array column? We can remove it later.
> > > >
> > > > Thanks, okay, temporarily I have added in a commit message how we can
> > > > fetch the data from the JSON array field. In next version I will add
> > > > a test to get the conflict stored in conflict log history table and
> > > > fetch from it.
> > >
> > > I noticed that the table structure can get changed by the time the
> > > conflict record is prepared. In ReportApplyConflict(), the code
> > > currently prepares the conflict log tuple before deciding whether the
> > > insertion will be immediate or deferred:
> > > + /* Insert conflict details to conflict log table. */
> > > + if (conflictlogrel)
> > > + {
> > > + /*
> > > + * Prepare the conflict log tuple. If the error level
> > > is below ERROR,
> > > + * insert it immediately. Otherwise, defer the
> > > insertion to a new
> > > + * transaction after the current one aborts, ensuring
> > > the insertion of
> > > + * the log tuple is not rolled back.
> > > + */
> > > + prepare_conflict_log_tuple(estate,
> > > +
> > > relinfo->ri_RelationDesc,
> > > +
> > > conflictlogrel,
> > > + type,
> > > + searchslot,
> > > +
> > > conflicttuples,
> > > + remoteslot);
> > > + if (elevel < ERROR)
> > > + InsertConflictLogTuple(conflictlogrel);
> > > +
> > > + table_close(conflictlogrel, RowExclusiveLock);
> > > + }
> > >
> > > If the conflict history table defintion is changed just before
> > > prepare_conflict_log_tuple, the tuple creation will crash:
> > > Program received signal SIGSEGV, Segmentation fault.
> > > 0x00005a342e01df4f in VARATT_CAN_MAKE_SHORT (PTR=0x4000) at
> > > ../../../../src/include/varatt.h:419
> > > 419 return VARATT_IS_4B_U(PTR) &&
> > > (gdb) bt
> > > #0 0x00005a342e01df4f in VARATT_CAN_MAKE_SHORT (PTR=0x4000) at
> > > ../../../../src/include/varatt.h:419
> > > #1 0x00005a342e01e5ed in heap_compute_data_size
> > > (tupleDesc=0x7ab405e5dda8, values=0x7ffd7af3ad20,
> > > isnull=0x7ffd7af3ad15) at heaptuple.c:239
> > > #2 0x00005a342e0200dd in heap_form_tuple
> > > (tupleDescriptor=0x7ab405e5dda8, values=0x7ffd7af3ad20,
> > > isnull=0x7ffd7af3ad15) at heaptuple.c:1158
> > > #3 0x00005a342e55e8c2 in prepare_conflict_log_tuple
> > > (estate=0x5a3467944530, rel=0x7ab405e594e8,
> > > conflictlogrel=0x7ab405e5da88, conflict_type=CT_INSERT_EXISTS,
> > > searchslot=0x0,
> > > conflicttuples=0x5a3467942da0, remoteslot=0x5a346792e498) at conflict.c:936
> > > #4 0x00005a342e55cea6 in ReportApplyConflict (estate=0x5a3467944530,
> > > relinfo=0x5a346792e778, elevel=21, type=CT_INSERT_EXISTS,
> > > searchslot=0x0, remoteslot=0x5a346792e498,
> > > conflicttuples=0x5a3467942da0) at conflict.c:168
> > > #5 0x00005a342e348c35 in CheckAndReportConflict
> > > (resultRelInfo=0x5a346792e778, estate=0x5a3467944530,
> > > type=CT_INSERT_EXISTS, recheckIndexes=0x5a3467942648, searchslot=0x0,
> > > remoteslot=0x5a346792e498) at execReplication.c:793
> > >
> > > This can be reproduced by the following steps:
> > > CREATE PUBLICATION pub;
> > > CREATE SUBSCRIPTION sub ... WITH (conflict_log_table = 'conflict');
> > > ALTER TABLE conflict RENAME TO conflict1:
> > > CREATE TABLE conflict(c1 varchar, c2 varchar);
> > > -- Cause a conflict, this will crash while trying to prepare the
> > > conflicting tuple
> >
> > Yeah while it is allowed to drop or alter the conflict log table, it
> > should not seg fault, IMHO error is acceptable as per the initial
> > discussion, so I will look into this and tighten up the logic so that
> > it will throw an error whenever it can not insert into the conflict
> > log table.
>
> I was thinking about the solution that we need to do if table
> definition is changed, one option is whenever we try to prepare the
> tuple after acquiring the lock we can validate the table definition if
> this doesn't qualify the standard conflict log table schema we can
> ERROR out. IMHO that should not be an issue as we are only doing this
> in conflict logging.
Should we emit a warning instead of error, to stay consistent with the
other exception case where a warning is raised when the conflict log
table does not exist?
+ /* Conflict log table is dropped or not accessible. */
+ if (conflictlogrel == NULL)
+ ereport(WARNING,
+ (errcode(ERRCODE_UNDEFINED_TABLE),
+ errmsg("conflict log table \"%s.%s\"
does not exist",
+
get_namespace_name(nspid), conflictlogtable)));
Regards,
Vignesh
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-08 04:06 Dilip Kumar <dilipbalaut@gmail.com>
parent: vignesh C <vignesh21@gmail.com>
0 siblings, 0 replies; 912+ messages in thread
From: Dilip Kumar @ 2025-12-08 04:06 UTC (permalink / raw)
To: vignesh C <vignesh21@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Mon, Dec 8, 2025 at 9:12 AM vignesh C <vignesh21@gmail.com> wrote:
>
> On Sat, 6 Dec 2025 at 20:36, Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Fri, Dec 5, 2025 at 10:39 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > On Thu, Dec 4, 2025 at 8:05 PM vignesh C <vignesh21@gmail.com> wrote:
> > > >
> > > > On Wed, 3 Dec 2025 at 16:57, Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > > >
> > > > > On Wed, Dec 3, 2025 at 9:49 AM shveta malik <shveta.malik@gmail.com> wrote:
> > > > > > >
> > > > > > > relid | 16391
> > > > > > > schemaname | public
> > > > > > > relname | conf_tab
> > > > > > > conflict_type | multiple_unique_conflicts
> > > > > > > remote_xid | 761
> > > > > > > remote_commit_lsn | 0/01761400
> > > > > > > remote_commit_ts | 2025-12-02 15:02:07.045935+00
> > > > > > > remote_origin | pg_16406
> > > > > > > key_tuple |
> > > > > > > remote_tuple | {"a":2,"b":3,"c":4}
> > > > > > > local_conflicts |
> > > > > > > {"{\"xid\":\"773\",\"commit_ts\":\"2025-12-02T15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":2,\"b\":2,\"c\":2}}","{\"xid\":\"
> > > > > > > 773\",\"commit_ts\":\"2025-12-02T15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":3,\"b\":3,\"c\":3}}","{\"xid\":\"773\",\"commit_ts\":\"2025-12-02T
> > > > > > > 15:02:00.640253+00:00\",\"origin\":\"\",\"tuple\":{\"a\":4,\"b\":4,\"c\":4}}"}
> > > > > > >
> > > > > >
> > > > > > Thanks, it looks good. For the benefit of others, could you include a
> > > > > > brief note, perhaps in the commit message for now, describing how to
> > > > > > access or read this array column? We can remove it later.
> > > > >
> > > > > Thanks, okay, temporarily I have added in a commit message how we can
> > > > > fetch the data from the JSON array field. In next version I will add
> > > > > a test to get the conflict stored in conflict log history table and
> > > > > fetch from it.
> > > >
> > > > I noticed that the table structure can get changed by the time the
> > > > conflict record is prepared. In ReportApplyConflict(), the code
> > > > currently prepares the conflict log tuple before deciding whether the
> > > > insertion will be immediate or deferred:
> > > > + /* Insert conflict details to conflict log table. */
> > > > + if (conflictlogrel)
> > > > + {
> > > > + /*
> > > > + * Prepare the conflict log tuple. If the error level
> > > > is below ERROR,
> > > > + * insert it immediately. Otherwise, defer the
> > > > insertion to a new
> > > > + * transaction after the current one aborts, ensuring
> > > > the insertion of
> > > > + * the log tuple is not rolled back.
> > > > + */
> > > > + prepare_conflict_log_tuple(estate,
> > > > +
> > > > relinfo->ri_RelationDesc,
> > > > +
> > > > conflictlogrel,
> > > > + type,
> > > > + searchslot,
> > > > +
> > > > conflicttuples,
> > > > + remoteslot);
> > > > + if (elevel < ERROR)
> > > > + InsertConflictLogTuple(conflictlogrel);
> > > > +
> > > > + table_close(conflictlogrel, RowExclusiveLock);
> > > > + }
> > > >
> > > > If the conflict history table defintion is changed just before
> > > > prepare_conflict_log_tuple, the tuple creation will crash:
> > > > Program received signal SIGSEGV, Segmentation fault.
> > > > 0x00005a342e01df4f in VARATT_CAN_MAKE_SHORT (PTR=0x4000) at
> > > > ../../../../src/include/varatt.h:419
> > > > 419 return VARATT_IS_4B_U(PTR) &&
> > > > (gdb) bt
> > > > #0 0x00005a342e01df4f in VARATT_CAN_MAKE_SHORT (PTR=0x4000) at
> > > > ../../../../src/include/varatt.h:419
> > > > #1 0x00005a342e01e5ed in heap_compute_data_size
> > > > (tupleDesc=0x7ab405e5dda8, values=0x7ffd7af3ad20,
> > > > isnull=0x7ffd7af3ad15) at heaptuple.c:239
> > > > #2 0x00005a342e0200dd in heap_form_tuple
> > > > (tupleDescriptor=0x7ab405e5dda8, values=0x7ffd7af3ad20,
> > > > isnull=0x7ffd7af3ad15) at heaptuple.c:1158
> > > > #3 0x00005a342e55e8c2 in prepare_conflict_log_tuple
> > > > (estate=0x5a3467944530, rel=0x7ab405e594e8,
> > > > conflictlogrel=0x7ab405e5da88, conflict_type=CT_INSERT_EXISTS,
> > > > searchslot=0x0,
> > > > conflicttuples=0x5a3467942da0, remoteslot=0x5a346792e498) at conflict.c:936
> > > > #4 0x00005a342e55cea6 in ReportApplyConflict (estate=0x5a3467944530,
> > > > relinfo=0x5a346792e778, elevel=21, type=CT_INSERT_EXISTS,
> > > > searchslot=0x0, remoteslot=0x5a346792e498,
> > > > conflicttuples=0x5a3467942da0) at conflict.c:168
> > > > #5 0x00005a342e348c35 in CheckAndReportConflict
> > > > (resultRelInfo=0x5a346792e778, estate=0x5a3467944530,
> > > > type=CT_INSERT_EXISTS, recheckIndexes=0x5a3467942648, searchslot=0x0,
> > > > remoteslot=0x5a346792e498) at execReplication.c:793
> > > >
> > > > This can be reproduced by the following steps:
> > > > CREATE PUBLICATION pub;
> > > > CREATE SUBSCRIPTION sub ... WITH (conflict_log_table = 'conflict');
> > > > ALTER TABLE conflict RENAME TO conflict1:
> > > > CREATE TABLE conflict(c1 varchar, c2 varchar);
> > > > -- Cause a conflict, this will crash while trying to prepare the
> > > > conflicting tuple
> > >
> > > Yeah while it is allowed to drop or alter the conflict log table, it
> > > should not seg fault, IMHO error is acceptable as per the initial
> > > discussion, so I will look into this and tighten up the logic so that
> > > it will throw an error whenever it can not insert into the conflict
> > > log table.
> >
> > I was thinking about the solution that we need to do if table
> > definition is changed, one option is whenever we try to prepare the
> > tuple after acquiring the lock we can validate the table definition if
> > this doesn't qualify the standard conflict log table schema we can
> > ERROR out. IMHO that should not be an issue as we are only doing this
> > in conflict logging.
>
> Should we emit a warning instead of error, to stay consistent with the
> other exception case where a warning is raised when the conflict log
> table does not exist?
> + /* Conflict log table is dropped or not accessible. */
> + if (conflictlogrel == NULL)
> + ereport(WARNING,
> + (errcode(ERRCODE_UNDEFINED_TABLE),
> + errmsg("conflict log table \"%s.%s\"
> does not exist",
> +
> get_namespace_name(nspid), conflictlogtable)));
Yes this should be WARNING.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-08 04:55 Dilip Kumar <dilipbalaut@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-12-08 04:55 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: Masahiko Sawada <sawada.mshk@gmail.com>; shveta malik <shveta.malik@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, Dec 4, 2025 at 4:20 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Thu, Dec 4, 2025 at 10:49 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > > ---
> > > I think the conflict history table should not be transferred to the
> > > new cluster when pg_upgrade since the table definition could be
> > > different across major versions.
> >
> > Let me think more on this with respect to behaviour of other factors
> > like subscriptions etc.
> >
>
> Can we deal with different schema of tables across versions via
> pg_dump/restore during upgrade?
>
While handling the case of conflict_log_table option during pg_dump, I
realized that the restore is trying to create conflict log table 2
different places 1) As part of the regular table dump 2) As part of
the CREATE SUBSCRIPTION when conflict_log_table option is set.
So one option is we can avoid dumping the conflict log tables as part
of the regular table dump if we think that we do not need to conflict
log table data and let it get created as part of the create
subscription command, OTOH if we think we want to keep the conflict
log table data, let it get dumped as part of the regular tables and in
CREATE SUBSCRIPTION we will just set the option but do not create the
table, although we might need to do special handling of this case
because if we allow the existing tables to be set as conflict log
tables then it may allow other user tables to be set, so need to think
how to handle this if we need to go with this option.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-08 09:08 Amit Kapila <amit.kapila16@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Amit Kapila @ 2025-12-08 09:08 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Masahiko Sawada <sawada.mshk@gmail.com>; shveta malik <shveta.malik@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Mon, Dec 8, 2025 at 10:25 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Thu, Dec 4, 2025 at 4:20 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> >
> > On Thu, Dec 4, 2025 at 10:49 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > > ---
> > > > I think the conflict history table should not be transferred to the
> > > > new cluster when pg_upgrade since the table definition could be
> > > > different across major versions.
> > >
> > > Let me think more on this with respect to behaviour of other factors
> > > like subscriptions etc.
> > >
> >
> > Can we deal with different schema of tables across versions via
> > pg_dump/restore during upgrade?
> >
>
> While handling the case of conflict_log_table option during pg_dump, I
> realized that the restore is trying to create conflict log table 2
> different places 1) As part of the regular table dump 2) As part of
> the CREATE SUBSCRIPTION when conflict_log_table option is set.
>
> So one option is we can avoid dumping the conflict log tables as part
> of the regular table dump if we think that we do not need to conflict
> log table data and let it get created as part of the create
> subscription command, OTOH if we think we want to keep the conflict
> log table data,
>
We want to retain conflict_history after upgrade. This is required for
various reasons (a) after upgrade DBA user will still require to
resolved the pending unresolved conflicts, (b) Regulations often
require keeping audit trails for a longer period of time. If a
conflict occurred at time X (which is less than the regulations
requirement) regarding a financial transaction, that record must
survive the upgrade, (c)
If something breaks after the upgrade (e.g., missing rows, constraint
violations), conflict history helps trace root causes. It shows
whether issues existed before the upgrade or were introduced during
migration, (d) as users can query the conflict_history tables, it
should be treated similar to user tables.
BTW, we are also planning to migrate commit_ts data in thread [1]
which would be helpful for conflict_resolutions after upgrade.
let it get dumped as part of the regular tables and in
> CREATE SUBSCRIPTION we will just set the option but do not create the
> table,
>
Yeah, we can turn this option during CREATE SUBSCRIPTION so that it
doesn't try to create the table again.
> although we might need to do special handling of this case
> because if we allow the existing tables to be set as conflict log
> tables then it may allow other user tables to be set, so need to think
> how to handle this if we need to go with this option.
>
Yeah, probably but it should be allowed internally only not to users.
I think we can split this upgrade handling as a top-up patch at least
for the purpose of review.
[1] - https://www.postgresql.org/message-id/182311743703924%40mail.yandex.ru
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-08 09:30 Dilip Kumar <dilipbalaut@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-12-08 09:30 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: Masahiko Sawada <sawada.mshk@gmail.com>; shveta malik <shveta.malik@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Mon, Dec 8, 2025 at 2:38 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Mon, Dec 8, 2025 at 10:25 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Thu, Dec 4, 2025 at 4:20 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > >
> > > On Thu, Dec 4, 2025 at 10:49 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > >
> > > > > ---
> > > > > I think the conflict history table should not be transferred to the
> > > > > new cluster when pg_upgrade since the table definition could be
> > > > > different across major versions.
> > > >
> > > > Let me think more on this with respect to behaviour of other factors
> > > > like subscriptions etc.
> > > >
> > >
> > > Can we deal with different schema of tables across versions via
> > > pg_dump/restore during upgrade?
> > >
> >
> > While handling the case of conflict_log_table option during pg_dump, I
> > realized that the restore is trying to create conflict log table 2
> > different places 1) As part of the regular table dump 2) As part of
> > the CREATE SUBSCRIPTION when conflict_log_table option is set.
> >
> > So one option is we can avoid dumping the conflict log tables as part
> > of the regular table dump if we think that we do not need to conflict
> > log table data and let it get created as part of the create
> > subscription command, OTOH if we think we want to keep the conflict
> > log table data,
> >
>
> We want to retain conflict_history after upgrade. This is required for
> various reasons (a) after upgrade DBA user will still require to
> resolved the pending unresolved conflicts, (b) Regulations often
> require keeping audit trails for a longer period of time. If a
> conflict occurred at time X (which is less than the regulations
> requirement) regarding a financial transaction, that record must
> survive the upgrade, (c)
> If something breaks after the upgrade (e.g., missing rows, constraint
> violations), conflict history helps trace root causes. It shows
> whether issues existed before the upgrade or were introduced during
> migration, (d) as users can query the conflict_history tables, it
> should be treated similar to user tables.
>
> BTW, we are also planning to migrate commit_ts data in thread [1]
> which would be helpful for conflict_resolutions after upgrade.
>
> let it get dumped as part of the regular tables and in
> > CREATE SUBSCRIPTION we will just set the option but do not create the
> > table,
> >
>
> Yeah, we can turn this option during CREATE SUBSCRIPTION so that it
> doesn't try to create the table again.
>
> > although we might need to do special handling of this case
> > because if we allow the existing tables to be set as conflict log
> > tables then it may allow other user tables to be set, so need to think
> > how to handle this if we need to go with this option.
> >
>
> Yeah, probably but it should be allowed internally only not to users.
Yeah I wanted to do that, but problem is with dump and restore, I mean
if you just dump into a sql file and execute the sql file at that time
the CREATE SUBSCRIPTION with conflict_log_table option will fail as
the table already exists because it was restored as part of the dump.
I know under binary upgrade we have binary_upgrade flag so can do
special handling not sure how to distinguish the sql executing as part
of the restore or normal sql execution by user?
> I think we can split this upgrade handling as a top-up patch at least
> for the purpose of review.
Make sense.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-08 09:51 Amit Kapila <amit.kapila16@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Amit Kapila @ 2025-12-08 09:51 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Masahiko Sawada <sawada.mshk@gmail.com>; shveta malik <shveta.malik@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Mon, Dec 8, 2025 at 3:01 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Mon, Dec 8, 2025 at 2:38 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> >
> > On Mon, Dec 8, 2025 at 10:25 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > On Thu, Dec 4, 2025 at 4:20 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > > >
> > > > On Thu, Dec 4, 2025 at 10:49 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > > >
> > > > > > ---
> > > > > > I think the conflict history table should not be transferred to the
> > > > > > new cluster when pg_upgrade since the table definition could be
> > > > > > different across major versions.
> > > > >
> > > > > Let me think more on this with respect to behaviour of other factors
> > > > > like subscriptions etc.
> > > > >
> > > >
> > > > Can we deal with different schema of tables across versions via
> > > > pg_dump/restore during upgrade?
> > > >
> > >
> > > While handling the case of conflict_log_table option during pg_dump, I
> > > realized that the restore is trying to create conflict log table 2
> > > different places 1) As part of the regular table dump 2) As part of
> > > the CREATE SUBSCRIPTION when conflict_log_table option is set.
> > >
> > > So one option is we can avoid dumping the conflict log tables as part
> > > of the regular table dump if we think that we do not need to conflict
> > > log table data and let it get created as part of the create
> > > subscription command, OTOH if we think we want to keep the conflict
> > > log table data,
> > >
> >
> > We want to retain conflict_history after upgrade. This is required for
> > various reasons (a) after upgrade DBA user will still require to
> > resolved the pending unresolved conflicts, (b) Regulations often
> > require keeping audit trails for a longer period of time. If a
> > conflict occurred at time X (which is less than the regulations
> > requirement) regarding a financial transaction, that record must
> > survive the upgrade, (c)
> > If something breaks after the upgrade (e.g., missing rows, constraint
> > violations), conflict history helps trace root causes. It shows
> > whether issues existed before the upgrade or were introduced during
> > migration, (d) as users can query the conflict_history tables, it
> > should be treated similar to user tables.
> >
> > BTW, we are also planning to migrate commit_ts data in thread [1]
> > which would be helpful for conflict_resolutions after upgrade.
> >
> > let it get dumped as part of the regular tables and in
> > > CREATE SUBSCRIPTION we will just set the option but do not create the
> > > table,
> > >
> >
> > Yeah, we can turn this option during CREATE SUBSCRIPTION so that it
> > doesn't try to create the table again.
> >
> > > although we might need to do special handling of this case
> > > because if we allow the existing tables to be set as conflict log
> > > tables then it may allow other user tables to be set, so need to think
> > > how to handle this if we need to go with this option.
> > >
> >
> > Yeah, probably but it should be allowed internally only not to users.
>
> Yeah I wanted to do that, but problem is with dump and restore, I mean
> if you just dump into a sql file and execute the sql file at that time
> the CREATE SUBSCRIPTION with conflict_log_table option will fail as
> the table already exists because it was restored as part of the dump.
> I know under binary upgrade we have binary_upgrade flag so can do
> special handling not sure how to distinguish the sql executing as part
> of the restore or normal sql execution by user?
>
See dumpSubscription(). We always use (connect = false) while dumping
subscription, so, similarly, we should always dump the new option with
default value which not to create the history table. Won't that be
sufficient?
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-08 11:45 Dilip Kumar <dilipbalaut@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-12-08 11:45 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: Masahiko Sawada <sawada.mshk@gmail.com>; shveta malik <shveta.malik@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Mon, Dec 8, 2025 at 3:21 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Mon, Dec 8, 2025 at 3:01 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Mon, Dec 8, 2025 at 2:38 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > >
> > > On Mon, Dec 8, 2025 at 10:25 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > >
> > > > On Thu, Dec 4, 2025 at 4:20 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > > > >
> > > > > On Thu, Dec 4, 2025 at 10:49 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > > > >
> > > > > > > ---
> > > > > > > I think the conflict history table should not be transferred to the
> > > > > > > new cluster when pg_upgrade since the table definition could be
> > > > > > > different across major versions.
> > > > > >
> > > > > > Let me think more on this with respect to behaviour of other factors
> > > > > > like subscriptions etc.
> > > > > >
> > > > >
> > > > > Can we deal with different schema of tables across versions via
> > > > > pg_dump/restore during upgrade?
> > > > >
> > > >
> > > > While handling the case of conflict_log_table option during pg_dump, I
> > > > realized that the restore is trying to create conflict log table 2
> > > > different places 1) As part of the regular table dump 2) As part of
> > > > the CREATE SUBSCRIPTION when conflict_log_table option is set.
> > > >
> > > > So one option is we can avoid dumping the conflict log tables as part
> > > > of the regular table dump if we think that we do not need to conflict
> > > > log table data and let it get created as part of the create
> > > > subscription command, OTOH if we think we want to keep the conflict
> > > > log table data,
> > > >
> > >
> > > We want to retain conflict_history after upgrade. This is required for
> > > various reasons (a) after upgrade DBA user will still require to
> > > resolved the pending unresolved conflicts, (b) Regulations often
> > > require keeping audit trails for a longer period of time. If a
> > > conflict occurred at time X (which is less than the regulations
> > > requirement) regarding a financial transaction, that record must
> > > survive the upgrade, (c)
> > > If something breaks after the upgrade (e.g., missing rows, constraint
> > > violations), conflict history helps trace root causes. It shows
> > > whether issues existed before the upgrade or were introduced during
> > > migration, (d) as users can query the conflict_history tables, it
> > > should be treated similar to user tables.
> > >
> > > BTW, we are also planning to migrate commit_ts data in thread [1]
> > > which would be helpful for conflict_resolutions after upgrade.
> > >
> > > let it get dumped as part of the regular tables and in
> > > > CREATE SUBSCRIPTION we will just set the option but do not create the
> > > > table,
> > > >
> > >
> > > Yeah, we can turn this option during CREATE SUBSCRIPTION so that it
> > > doesn't try to create the table again.
> > >
> > > > although we might need to do special handling of this case
> > > > because if we allow the existing tables to be set as conflict log
> > > > tables then it may allow other user tables to be set, so need to think
> > > > how to handle this if we need to go with this option.
> > > >
> > >
> > > Yeah, probably but it should be allowed internally only not to users.
> >
> > Yeah I wanted to do that, but problem is with dump and restore, I mean
> > if you just dump into a sql file and execute the sql file at that time
> > the CREATE SUBSCRIPTION with conflict_log_table option will fail as
> > the table already exists because it was restored as part of the dump.
> > I know under binary upgrade we have binary_upgrade flag so can do
> > special handling not sure how to distinguish the sql executing as part
> > of the restore or normal sql execution by user?
> >
>
> See dumpSubscription(). We always use (connect = false) while dumping
> subscription, so, similarly, we should always dump the new option with
> default value which not to create the history table. Won't that be
> sufficient?
Thinking out loud, so basically what we need is we need to create
subscription and set the conflict log table in catalog entry of the
subscription in pg_subscription but do not want to create the conflict
log table, so seems like we need to invent something new which set the
conflict log table in catalog but do not create the table. Currently
we have a single option that if conflict_log_table='table_name' is set
then we will create the table as well as set the table name in the
catalog, so need to think of something on the line of separating this,
or something more innovative.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-09 04:42 Amit Kapila <amit.kapila16@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Amit Kapila @ 2025-12-09 04:42 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Masahiko Sawada <sawada.mshk@gmail.com>; shveta malik <shveta.malik@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Mon, Dec 8, 2025 at 5:15 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Mon, Dec 8, 2025 at 3:21 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> >
> > On Mon, Dec 8, 2025 at 3:01 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > On Mon, Dec 8, 2025 at 2:38 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > > >
> > > > On Mon, Dec 8, 2025 at 10:25 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > > >
> > > > > On Thu, Dec 4, 2025 at 4:20 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > > > > >
> > > > > > On Thu, Dec 4, 2025 at 10:49 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > > > > >
> > > > > > > > ---
> > > > > > > > I think the conflict history table should not be transferred to the
> > > > > > > > new cluster when pg_upgrade since the table definition could be
> > > > > > > > different across major versions.
> > > > > > >
> > > > > > > Let me think more on this with respect to behaviour of other factors
> > > > > > > like subscriptions etc.
> > > > > > >
> > > > > >
> > > > > > Can we deal with different schema of tables across versions via
> > > > > > pg_dump/restore during upgrade?
> > > > > >
> > > > >
> > > > > While handling the case of conflict_log_table option during pg_dump, I
> > > > > realized that the restore is trying to create conflict log table 2
> > > > > different places 1) As part of the regular table dump 2) As part of
> > > > > the CREATE SUBSCRIPTION when conflict_log_table option is set.
> > > > >
> > > > > So one option is we can avoid dumping the conflict log tables as part
> > > > > of the regular table dump if we think that we do not need to conflict
> > > > > log table data and let it get created as part of the create
> > > > > subscription command, OTOH if we think we want to keep the conflict
> > > > > log table data,
> > > > >
> > > >
> > > > We want to retain conflict_history after upgrade. This is required for
> > > > various reasons (a) after upgrade DBA user will still require to
> > > > resolved the pending unresolved conflicts, (b) Regulations often
> > > > require keeping audit trails for a longer period of time. If a
> > > > conflict occurred at time X (which is less than the regulations
> > > > requirement) regarding a financial transaction, that record must
> > > > survive the upgrade, (c)
> > > > If something breaks after the upgrade (e.g., missing rows, constraint
> > > > violations), conflict history helps trace root causes. It shows
> > > > whether issues existed before the upgrade or were introduced during
> > > > migration, (d) as users can query the conflict_history tables, it
> > > > should be treated similar to user tables.
> > > >
> > > > BTW, we are also planning to migrate commit_ts data in thread [1]
> > > > which would be helpful for conflict_resolutions after upgrade.
> > > >
> > > > let it get dumped as part of the regular tables and in
> > > > > CREATE SUBSCRIPTION we will just set the option but do not create the
> > > > > table,
> > > > >
> > > >
> > > > Yeah, we can turn this option during CREATE SUBSCRIPTION so that it
> > > > doesn't try to create the table again.
> > > >
> > > > > although we might need to do special handling of this case
> > > > > because if we allow the existing tables to be set as conflict log
> > > > > tables then it may allow other user tables to be set, so need to think
> > > > > how to handle this if we need to go with this option.
> > > > >
> > > >
> > > > Yeah, probably but it should be allowed internally only not to users.
> > >
> > > Yeah I wanted to do that, but problem is with dump and restore, I mean
> > > if you just dump into a sql file and execute the sql file at that time
> > > the CREATE SUBSCRIPTION with conflict_log_table option will fail as
> > > the table already exists because it was restored as part of the dump.
> > > I know under binary upgrade we have binary_upgrade flag so can do
> > > special handling not sure how to distinguish the sql executing as part
> > > of the restore or normal sql execution by user?
> > >
> >
> > See dumpSubscription(). We always use (connect = false) while dumping
> > subscription, so, similarly, we should always dump the new option with
> > default value which not to create the history table. Won't that be
> > sufficient?
>
> Thinking out loud, so basically what we need is we need to create
> subscription and set the conflict log table in catalog entry of the
> subscription in pg_subscription but do not want to create the conflict
> log table, so seems like we need to invent something new which set the
> conflict log table in catalog but do not create the table. Currently
> we have a single option that if conflict_log_table='table_name' is set
> then we will create the table as well as set the table name in the
> catalog, so need to think of something on the line of separating this,
> or something more innovative.
>
This needs more thought and discussion, so it is better to separate
out this part at this stage and let's try to review the core patch
first. BTW, I told a few days back to have two options (instead of a
single option conflict_log_table) to allow extension of more ways to
LOG the conflict data.
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-09 06:36 Dilip Kumar <dilipbalaut@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-12-09 06:36 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: Masahiko Sawada <sawada.mshk@gmail.com>; shveta malik <shveta.malik@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, Dec 9, 2025 at 10:12 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Mon, Dec 8, 2025 at 5:15 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Mon, Dec 8, 2025 at 3:21 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > >
> > > On Mon, Dec 8, 2025 at 3:01 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > >
> > > > On Mon, Dec 8, 2025 at 2:38 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > > > >
> > > > > On Mon, Dec 8, 2025 at 10:25 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > > > >
> > > > > > On Thu, Dec 4, 2025 at 4:20 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > > > > > >
> > > > > > > On Thu, Dec 4, 2025 at 10:49 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > > > > > >
> > > > > > > > > ---
> > > > > > > > > I think the conflict history table should not be transferred to the
> > > > > > > > > new cluster when pg_upgrade since the table definition could be
> > > > > > > > > different across major versions.
> > > > > > > >
> > > > > > > > Let me think more on this with respect to behaviour of other factors
> > > > > > > > like subscriptions etc.
> > > > > > > >
> > > > > > >
> > > > > > > Can we deal with different schema of tables across versions via
> > > > > > > pg_dump/restore during upgrade?
> > > > > > >
> > > > > >
> > > > > > While handling the case of conflict_log_table option during pg_dump, I
> > > > > > realized that the restore is trying to create conflict log table 2
> > > > > > different places 1) As part of the regular table dump 2) As part of
> > > > > > the CREATE SUBSCRIPTION when conflict_log_table option is set.
> > > > > >
> > > > > > So one option is we can avoid dumping the conflict log tables as part
> > > > > > of the regular table dump if we think that we do not need to conflict
> > > > > > log table data and let it get created as part of the create
> > > > > > subscription command, OTOH if we think we want to keep the conflict
> > > > > > log table data,
> > > > > >
> > > > >
> > > > > We want to retain conflict_history after upgrade. This is required for
> > > > > various reasons (a) after upgrade DBA user will still require to
> > > > > resolved the pending unresolved conflicts, (b) Regulations often
> > > > > require keeping audit trails for a longer period of time. If a
> > > > > conflict occurred at time X (which is less than the regulations
> > > > > requirement) regarding a financial transaction, that record must
> > > > > survive the upgrade, (c)
> > > > > If something breaks after the upgrade (e.g., missing rows, constraint
> > > > > violations), conflict history helps trace root causes. It shows
> > > > > whether issues existed before the upgrade or were introduced during
> > > > > migration, (d) as users can query the conflict_history tables, it
> > > > > should be treated similar to user tables.
> > > > >
> > > > > BTW, we are also planning to migrate commit_ts data in thread [1]
> > > > > which would be helpful for conflict_resolutions after upgrade.
> > > > >
> > > > > let it get dumped as part of the regular tables and in
> > > > > > CREATE SUBSCRIPTION we will just set the option but do not create the
> > > > > > table,
> > > > > >
> > > > >
> > > > > Yeah, we can turn this option during CREATE SUBSCRIPTION so that it
> > > > > doesn't try to create the table again.
> > > > >
> > > > > > although we might need to do special handling of this case
> > > > > > because if we allow the existing tables to be set as conflict log
> > > > > > tables then it may allow other user tables to be set, so need to think
> > > > > > how to handle this if we need to go with this option.
> > > > > >
> > > > >
> > > > > Yeah, probably but it should be allowed internally only not to users.
> > > >
> > > > Yeah I wanted to do that, but problem is with dump and restore, I mean
> > > > if you just dump into a sql file and execute the sql file at that time
> > > > the CREATE SUBSCRIPTION with conflict_log_table option will fail as
> > > > the table already exists because it was restored as part of the dump.
> > > > I know under binary upgrade we have binary_upgrade flag so can do
> > > > special handling not sure how to distinguish the sql executing as part
> > > > of the restore or normal sql execution by user?
> > > >
> > >
> > > See dumpSubscription(). We always use (connect = false) while dumping
> > > subscription, so, similarly, we should always dump the new option with
> > > default value which not to create the history table. Won't that be
> > > sufficient?
> >
> > Thinking out loud, so basically what we need is we need to create
> > subscription and set the conflict log table in catalog entry of the
> > subscription in pg_subscription but do not want to create the conflict
> > log table, so seems like we need to invent something new which set the
> > conflict log table in catalog but do not create the table. Currently
> > we have a single option that if conflict_log_table='table_name' is set
> > then we will create the table as well as set the table name in the
> > catalog, so need to think of something on the line of separating this,
> > or something more innovative.
> >
>
> This needs more thought and discussion, so it is better to separate
> out this part at this stage and let's try to review the core patch
> first.
+1
BTW, I told a few days back to have two options (instead of a
> single option conflict_log_table) to allow extension of more ways to
> LOG the conflict data.
Yeah, I will put that as well in an add on patch, once I fix all the
option issues of the core patch.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-09 15:11 Dilip Kumar <dilipbalaut@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-12-09 15:11 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: Masahiko Sawada <sawada.mshk@gmail.com>; shveta malik <shveta.malik@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, Dec 9, 2025 at 12:06 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Tue, Dec 9, 2025 at 10:12 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
> >
> > On Mon, Dec 8, 2025 at 5:15 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > On Mon, Dec 8, 2025 at 3:21 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > > >
> > > > On Mon, Dec 8, 2025 at 3:01 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > > >
> > > > > On Mon, Dec 8, 2025 at 2:38 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > > > > >
> > > > > > On Mon, Dec 8, 2025 at 10:25 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > > > > >
> > > > > > > On Thu, Dec 4, 2025 at 4:20 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > > > > > > >
> > > > > > > > On Thu, Dec 4, 2025 at 10:49 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > > > > > > >
> > > > > > > > > > ---
> > > > > > > > > > I think the conflict history table should not be transferred to the
> > > > > > > > > > new cluster when pg_upgrade since the table definition could be
> > > > > > > > > > different across major versions.
> > > > > > > > >
> > > > > > > > > Let me think more on this with respect to behaviour of other factors
> > > > > > > > > like subscriptions etc.
> > > > > > > > >
> > > > > > > >
> > > > > > > > Can we deal with different schema of tables across versions via
> > > > > > > > pg_dump/restore during upgrade?
> > > > > > > >
> > > > > > >
> > > > > > > While handling the case of conflict_log_table option during pg_dump, I
> > > > > > > realized that the restore is trying to create conflict log table 2
> > > > > > > different places 1) As part of the regular table dump 2) As part of
> > > > > > > the CREATE SUBSCRIPTION when conflict_log_table option is set.
> > > > > > >
> > > > > > > So one option is we can avoid dumping the conflict log tables as part
> > > > > > > of the regular table dump if we think that we do not need to conflict
> > > > > > > log table data and let it get created as part of the create
> > > > > > > subscription command, OTOH if we think we want to keep the conflict
> > > > > > > log table data,
> > > > > > >
> > > > > >
> > > > > > We want to retain conflict_history after upgrade. This is required for
> > > > > > various reasons (a) after upgrade DBA user will still require to
> > > > > > resolved the pending unresolved conflicts, (b) Regulations often
> > > > > > require keeping audit trails for a longer period of time. If a
> > > > > > conflict occurred at time X (which is less than the regulations
> > > > > > requirement) regarding a financial transaction, that record must
> > > > > > survive the upgrade, (c)
> > > > > > If something breaks after the upgrade (e.g., missing rows, constraint
> > > > > > violations), conflict history helps trace root causes. It shows
> > > > > > whether issues existed before the upgrade or were introduced during
> > > > > > migration, (d) as users can query the conflict_history tables, it
> > > > > > should be treated similar to user tables.
> > > > > >
> > > > > > BTW, we are also planning to migrate commit_ts data in thread [1]
> > > > > > which would be helpful for conflict_resolutions after upgrade.
> > > > > >
> > > > > > let it get dumped as part of the regular tables and in
> > > > > > > CREATE SUBSCRIPTION we will just set the option but do not create the
> > > > > > > table,
> > > > > > >
> > > > > >
> > > > > > Yeah, we can turn this option during CREATE SUBSCRIPTION so that it
> > > > > > doesn't try to create the table again.
> > > > > >
> > > > > > > although we might need to do special handling of this case
> > > > > > > because if we allow the existing tables to be set as conflict log
> > > > > > > tables then it may allow other user tables to be set, so need to think
> > > > > > > how to handle this if we need to go with this option.
> > > > > > >
> > > > > >
> > > > > > Yeah, probably but it should be allowed internally only not to users.
> > > > >
> > > > > Yeah I wanted to do that, but problem is with dump and restore, I mean
> > > > > if you just dump into a sql file and execute the sql file at that time
> > > > > the CREATE SUBSCRIPTION with conflict_log_table option will fail as
> > > > > the table already exists because it was restored as part of the dump.
> > > > > I know under binary upgrade we have binary_upgrade flag so can do
> > > > > special handling not sure how to distinguish the sql executing as part
> > > > > of the restore or normal sql execution by user?
> > > > >
> > > >
> > > > See dumpSubscription(). We always use (connect = false) while dumping
> > > > subscription, so, similarly, we should always dump the new option with
> > > > default value which not to create the history table. Won't that be
> > > > sufficient?
> > >
> > > Thinking out loud, so basically what we need is we need to create
> > > subscription and set the conflict log table in catalog entry of the
> > > subscription in pg_subscription but do not want to create the conflict
> > > log table, so seems like we need to invent something new which set the
> > > conflict log table in catalog but do not create the table. Currently
> > > we have a single option that if conflict_log_table='table_name' is set
> > > then we will create the table as well as set the table name in the
> > > catalog, so need to think of something on the line of separating this,
> > > or something more innovative.
> > >
> >
> > This needs more thought and discussion, so it is better to separate
> > out this part at this stage and let's try to review the core patch
> > first.
>
> +1
>
> BTW, I told a few days back to have two options (instead of a
> > single option conflict_log_table) to allow extension of more ways to
> > LOG the conflict data.
>
> Yeah, I will put that as well in an add on patch, once I fix all the
> option issues of the core patch.
>
Here is the updated version of patch
What has changed
1. Table is created using create_heap_with_catalog() instead of SPI as
suggested by Sawada-San and Amit Kapila.
2. Validated the table schema after acquiring the lock before
preparing/inserting conflict tuples for defects raised by Vignesh.
3. Bug fixes raised by Shweta (segfault)
3. Comments from Peter (except exposing namespace in \dRs+, it's still pending.
What's not done/pending
1. Adding for key_tuple/RI as pointed by Shveta - will do in next version
2. Adding dependency of subscription on table so that we are not
allowed to drop the table - I think when we put the dependency on
shared objects those can not be dropped even with cascade option, but
I am still exploring more on this.
3. dump/restore and upgrade, I have partially working patch but then I
need to figure out how to skip table creation while creating
subscription, while discussing offlist with Hannu, he suggested we can
do something with dump dependency ordering, e.g. we can dump create
subscription first and then dump the clt data without actually dumping
the clt definition, with that table will be created while creating the
subscription and then data will be restored with COPY command, I will
explore more on this.
4. Test case for conflit insertion
5. Documentation patch
--
Regards,
Dilip Kumar
Google
Attachments:
[application/octet-stream] v10-0001-Add-configurable-conflict-log-table-for-Logical-.patch (122.2K, ../../CAFiTN-sJbhPX+LbA8YuQeYJpfGA2XA+OKXf8jCm04RoJOyzLvw@mail.gmail.com/2-v10-0001-Add-configurable-conflict-log-table-for-Logical-.patch)
download | inline diff:
From 991a9d075b3657b34bc31d83d93aef3329e65feb Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 12 Nov 2025 10:43:19 +0530
Subject: [PATCH v10] Add configurable conflict log table for Logical
Replication
This patch adds a feature to provide a structured, queryable record of all
logical replication conflicts. The current approach of logging conflicts as
plain text in the server logs makes it difficult to query, analyze, and
use for external monitoring and automation.
This patch addresses these limitations by introducing a configurable
conflict_log_table option in the CREATE SUBSCRIPTION command. Key design
decisions include:
User-Managed Table: The conflict log is stored in a user-managed table
rather than a system catalog.
Structured Data: Conflict details, including the original and remote tuples,
are stored in JSON columns, providing a flexible format to accommodate different
table schemas.
Comprehensive Information: The log table captures essential attributes such as
local and remote transaction IDs, LSNs, commit timestamps, and conflict type,
providing a complete record for post-mortem analysis.
This feature will make logical replication conflicts easier to monitor and manage,
significantly improving the overall resilience and operability of replication setups.
The conflict log tables will not be included in a publication, even if the publication
is configured to include ALL TABLES or ALL TABLES IN SCHEMA.
Note: A single remote tuple may conflict with multiple local tuples when conflict type
is CT_MULTIPLE_UNIQUE_CONFLICTS, so for handling this case we create a single row in
conflict log table with respect to each remote conflict tuple even if it conflicts with
multiple local tuples and we store the multiple conflict tuples as a single JSON array
element in format as
[ { "xid": "1001", "commit_ts": "...", "origin": "...", "tuple": {...} }, ... ]
We can extract the elements of the local tuples from the conflict log table row
as given in below example.
SELECT remote_xid, relname, remote_origin, local_conflicts[1] ->> 'xid' AS local_xid,
local_conflicts[1] ->> 'tuple' AS local_tuple
FROM myschema.conflict_log_history2;
remote_xid | relname | remote_origin | local_xid | local_tuple
------------+----------+---------------+-----------+---------------------
760 | test | pg_16406 | 771 | {"a":1,"b":10}
765 | conf_tab | pg_16406 | 775 | {"a":2,"b":2,"c":2}
---
src/backend/catalog/pg_publication.c | 26 +-
src/backend/commands/subscriptioncmds.c | 300 ++++++++++-
src/backend/replication/logical/conflict.c | 586 ++++++++++++++++++++-
src/backend/replication/logical/launcher.c | 1 +
src/backend/replication/logical/worker.c | 37 +-
src/backend/utils/cache/lsyscache.c | 38 ++
src/bin/psql/describe.c | 8 +-
src/bin/psql/tab-complete.in.c | 8 +-
src/include/catalog/pg_subscription.h | 5 +
src/include/commands/subscriptioncmds.h | 2 +
src/include/replication/conflict.h | 5 +
src/include/replication/worker_internal.h | 7 +
src/include/utils/lsyscache.h | 1 +
src/test/regress/expected/subscription.out | 360 +++++++++----
src/test/regress/sql/subscription.sql | 108 ++++
15 files changed, 1370 insertions(+), 122 deletions(-)
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index 0994220c53d..a0efac9fa50 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -31,6 +31,7 @@
#include "catalog/pg_publication_rel.h"
#include "catalog/pg_type.h"
#include "commands/publicationcmds.h"
+#include "commands/subscriptioncmds.h"
#include "funcapi.h"
#include "utils/array.h"
#include "utils/builtins.h"
@@ -85,6 +86,15 @@ check_publication_add_relation(Relation targetrel)
errmsg("cannot add relation \"%s\" to publication",
RelationGetRelationName(targetrel)),
errdetail("This operation is not supported for unlogged tables.")));
+
+ /* Can't be conflict log table */
+ if (IsConflictLogTable(RelationGetRelid(targetrel)))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot add relation \"%s.%s\" to publication",
+ get_namespace_name(RelationGetNamespace(targetrel)),
+ RelationGetRelationName(targetrel)),
+ errdetail("This operation is not supported for conflict log tables.")));
}
/*
@@ -145,6 +155,13 @@ is_publishable_class(Oid relid, Form_pg_class reltuple)
/*
* Another variant of is_publishable_class(), taking a Relation.
+ *
+ * Note: Conflict log tables are not publishable. However, we intentionally
+ * skip this check here because this function is called for every change and
+ * performing this check during every change publication is costly. To ensure
+ * unpublishable entries are ignored without incurring performance overhead,
+ * tuples inserted into the conflict log table uses the HEAP_INSERT_NO_LOGICAL
+ * flag. This allows the decoding layer to bypass these entries automatically.
*/
bool
is_publishable_relation(Relation rel)
@@ -169,7 +186,10 @@ pg_relation_is_publishable(PG_FUNCTION_ARGS)
tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
if (!HeapTupleIsValid(tuple))
PG_RETURN_NULL();
- result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple));
+
+ /* Subscription conflict log tables are not published */
+ result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple)) &&
+ !IsConflictLogTable(relid);
ReleaseSysCache(tuple);
PG_RETURN_BOOL(result);
}
@@ -890,7 +910,9 @@ GetAllPublicationRelations(char relkind, bool pubviaroot)
Form_pg_class relForm = (Form_pg_class) GETSTRUCT(tuple);
Oid relid = relForm->oid;
+ /* Subscription conflict log tables are not published */
if (is_publishable_class(relid, relForm) &&
+ !IsConflictLogTable(relid) &&
!(relForm->relispartition && pubviaroot))
result = lappend_oid(result, relid);
}
@@ -1018,7 +1040,7 @@ GetSchemaPublicationRelations(Oid schemaid, PublicationPartOpt pub_partopt)
Oid relid = relForm->oid;
char relkind;
- if (!is_publishable_class(relid, relForm))
+ if (!is_publishable_class(relid, relForm) || IsConflictLogTable(relid))
continue;
relkind = get_rel_relkind(relid);
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index 24b70234b35..4282459254e 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -15,16 +15,19 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/htup_details.h"
#include "access/table.h"
#include "access/twophase.h"
#include "access/xact.h"
#include "catalog/catalog.h"
#include "catalog/dependency.h"
+#include "catalog/heap.h"
#include "catalog/indexing.h"
#include "catalog/namespace.h"
#include "catalog/objectaccess.h"
#include "catalog/objectaddress.h"
+#include "catalog/pg_am_d.h"
#include "catalog/pg_authid_d.h"
#include "catalog/pg_database_d.h"
#include "catalog/pg_subscription.h"
@@ -34,6 +37,8 @@
#include "commands/event_trigger.h"
#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "executor/spi.h"
+#include "funcapi.h"
#include "miscadmin.h"
#include "nodes/makefuncs.h"
#include "pgstat.h"
@@ -47,10 +52,12 @@
#include "storage/lmgr.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/guc.h"
#include "utils/lsyscache.h"
#include "utils/memutils.h"
#include "utils/pg_lsn.h"
+#include "utils/regproc.h"
#include "utils/syscache.h"
/*
@@ -75,6 +82,7 @@
#define SUBOPT_MAX_RETENTION_DURATION 0x00008000
#define SUBOPT_LSN 0x00010000
#define SUBOPT_ORIGIN 0x00020000
+#define SUBOPT_CONFLICT_LOG_TABLE 0x00040000
/* check if the 'val' has 'bits' set */
#define IsSet(val, bits) (((val) & (bits)) == (bits))
@@ -103,6 +111,7 @@ typedef struct SubOpts
bool retaindeadtuples;
int32 maxretention;
char *origin;
+ char *conflictlogtable;
XLogRecPtr lsn;
} SubOpts;
@@ -135,7 +144,9 @@ static List *merge_publications(List *oldpublist, List *newpublist, bool addpub,
static void ReportSlotConnectionError(List *rstates, Oid subid, char *slotname, char *err);
static void CheckAlterSubOption(Subscription *sub, const char *option,
bool slot_needs_update, bool isTopLevel);
-
+static void create_conflict_log_table(Oid namespaceId, char *conflictrel,
+ Oid subid);
+static void drop_conflict_log_table(Oid namespaceId, char *conflictrel);
/*
* Common option parsing function for CREATE and ALTER SUBSCRIPTION commands.
@@ -191,6 +202,8 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->maxretention = 0;
if (IsSet(supported_opts, SUBOPT_ORIGIN))
opts->origin = pstrdup(LOGICALREP_ORIGIN_ANY);
+ if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_TABLE))
+ opts->conflictlogtable = NULL;
/* Parse options */
foreach(lc, stmt_options)
@@ -402,6 +415,19 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->specified_opts |= SUBOPT_LSN;
opts->lsn = lsn;
}
+ else if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_TABLE) &&
+ strcmp(defel->defname, "conflict_log_table") == 0)
+ {
+ if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_LOG_TABLE))
+ errorConflictingDefElem(defel, pstate);
+
+ opts->specified_opts |= SUBOPT_CONFLICT_LOG_TABLE;
+ opts->conflictlogtable = defGetString(defel);
+
+ /* Setting conflict_log_table = NONE is treated as no table. */
+ if (strcmp(opts->conflictlogtable, "none") == 0)
+ opts->conflictlogtable = NULL;
+ }
else
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
@@ -599,6 +625,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bits32 supported_opts;
SubOpts opts = {0};
AclResult aclresult;
+ Oid conflictlogtable_nspid = InvalidOid;
+ char *conflictlogtable = NULL;
/*
* Parse and check options.
@@ -612,7 +640,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
SUBOPT_DISABLE_ON_ERR | SUBOPT_PASSWORD_REQUIRED |
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
- SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN);
+ SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_TABLE);
parse_subscription_options(pstate, stmt->options, supported_opts, &opts);
/*
@@ -747,6 +776,25 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
values[Anum_pg_subscription_suborigin - 1] =
CStringGetTextDatum(opts.origin);
+ /*
+ * If a conflict log table name is specified, parse the schema and table
+ * name from the string. Store the namespace OID and the table name in
+ * the pg_subscription catalog tuple.
+ */
+ if (opts.conflictlogtable)
+ {
+ List *names = stringToQualifiedNameList(opts.conflictlogtable, NULL);
+
+ conflictlogtable_nspid =
+ QualifiedNameGetCreationNamespace(names, &conflictlogtable);
+ values[Anum_pg_subscription_subconflictlognspid - 1] =
+ ObjectIdGetDatum(conflictlogtable_nspid);
+ values[Anum_pg_subscription_subconflictlogtable - 1] =
+ CStringGetTextDatum(conflictlogtable);
+ }
+ else
+ nulls[Anum_pg_subscription_subconflictlogtable - 1] = true;
+
tup = heap_form_tuple(RelationGetDescr(rel), values, nulls);
/* Insert tuple into catalog. */
@@ -768,6 +816,11 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
ReplicationOriginNameForLogicalRep(subid, InvalidOid, originname, sizeof(originname));
replorigin_create(originname);
+ /* If a conflict log table name is given then create the table. */
+ if (opts.conflictlogtable)
+ create_conflict_log_table(conflictlogtable_nspid, conflictlogtable,
+ subid);
+
/*
* Connect to remote side to execute requested commands and fetch table
* and sequence info.
@@ -1410,7 +1463,8 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
SUBOPT_MAX_RETENTION_DURATION |
- SUBOPT_ORIGIN);
+ SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_TABLE);
parse_subscription_options(pstate, stmt->options,
supported_opts, &opts);
@@ -1665,6 +1719,73 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
origin = opts.origin;
}
+ if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_LOG_TABLE))
+ {
+ Oid nspid = InvalidOid;
+ char *relname = NULL;
+ Oid old_nspid = InvalidOid;
+ char *old_relname = NULL;
+ List *names = NIL;
+
+ /* Fetch the eixsting conflict table table information. */
+ old_relname =
+ get_subscription_conflict_log_table(subid, &old_nspid);
+ if (opts.conflictlogtable != NULL)
+ {
+ names = stringToQualifiedNameList(opts.conflictlogtable,
+ NULL);
+ nspid = QualifiedNameGetCreationNamespace(names, &relname);
+ }
+
+ /*
+ * If the subscription already uses this conflict log table
+ * and it exists, just issue a notice.
+ */
+ if (old_relname != NULL && relname != NULL &&
+ strcmp(old_relname, relname) == 0 &&
+ old_nspid == nspid &&
+ OidIsValid(get_relname_relid(relname, nspid)))
+ {
+ char *nspname = get_namespace_name(nspid);
+
+ ereport(NOTICE,
+ (errmsg("\"%s.%s\" is already in use as the conflict log table for this subscription",
+ nspname, relname)));
+ pfree(nspname);
+ }
+ else
+ {
+ /* Need to drop the old table if one was set. */
+ if (old_relname != NULL)
+ drop_conflict_log_table(old_nspid, old_relname);
+
+ /*
+ * Need to create a new table if a new name was
+ * provided.
+ */
+ if (relname != NULL)
+ create_conflict_log_table(nspid, relname, subid);
+
+ values[Anum_pg_subscription_subconflictlognspid - 1] =
+ ObjectIdGetDatum(nspid);
+
+ if (relname != NULL)
+ values[Anum_pg_subscription_subconflictlogtable - 1] =
+ CStringGetTextDatum(relname);
+ else
+ nulls[Anum_pg_subscription_subconflictlogtable - 1] =
+ true;
+
+ replaces[Anum_pg_subscription_subconflictlognspid - 1] =
+ true;
+ replaces[Anum_pg_subscription_subconflictlogtable - 1] =
+ true;
+ }
+
+ if (old_relname != NULL)
+ pfree(old_relname);
+ }
+
update_tuple = true;
break;
}
@@ -2027,6 +2148,8 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
Form_pg_subscription form;
List *rstates;
bool must_use_password;
+ Oid conflictlogtable_nsp = InvalidOid;
+ char *conflictlogtable = NULL;
/*
* The launcher may concurrently start a new worker for this subscription.
@@ -2110,6 +2233,20 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
ObjectAddressSet(myself, SubscriptionRelationId, subid);
EventTriggerSQLDropAddObject(&myself, true, true);
+ /* Fetch the conflict log table information. */
+ conflictlogtable =
+ get_subscription_conflict_log_table(subid, &conflictlogtable_nsp);
+
+ /*
+ * If the subscription had a conflict log table, drop it now. This happens
+ * before deleting the subscription tuple.
+ */
+ if (conflictlogtable)
+ {
+ drop_conflict_log_table(conflictlogtable_nsp, conflictlogtable);
+ pfree(conflictlogtable);
+ }
+
/* Remove the tuple from catalog. */
CatalogTupleDelete(rel, &tup->t_self);
@@ -3188,3 +3325,160 @@ defGetStreamingMode(DefElem *def)
def->defname)));
return LOGICALREP_STREAM_OFF; /* keep compiler quiet */
}
+
+/*
+ * Builds the TupleDesc for the conflict log table.
+ */
+static TupleDesc
+create_conflict_log_table_tupdesc(void)
+{
+ TupleDesc tupdesc;
+ int i = 0;
+#define NUM_CONFLICT_LOG_COLS 11
+ tupdesc = CreateTemplateTupleDesc(NUM_CONFLICT_LOG_COLS);
+
+ /* 1: relid Oid */
+ TupleDescInitEntry(tupdesc, ++i, "relid", OIDOID, -1, 0);
+
+ /* 2: schemaname TEXT */
+ TupleDescInitEntry(tupdesc, ++i, "schemaname", TEXTOID, -1, 0);
+
+ /* 3: relname TEXT */
+ TupleDescInitEntry(tupdesc, ++i, "relname", TEXTOID, -1, 0);
+
+ /* 4: conflict_type TEXT */
+ TupleDescInitEntry(tupdesc, ++i, "conflict_type", TEXTOID, -1, 0);
+
+ /* 5: remote_xid xid */
+ TupleDescInitEntry(tupdesc, ++i, "remote_xid", XIDOID, -1, 0);
+
+ /* 6: remote_commit_lsn pg_lsn */
+ TupleDescInitEntry(tupdesc, ++i, "remote_commit_lsn", PG_LSNOID, -1, 0);
+
+ /* 7: remote_commit_ts TIMESTAMPTZ */
+ TupleDescInitEntry(tupdesc, ++i, "remote_commit_ts", TIMESTAMPTZOID, -1, 0);
+
+ /* 8: remote_origin TEXT */
+ TupleDescInitEntry(tupdesc, ++i, "remote_origin", TEXTOID, -1, 0);
+
+ /* 9: key_tuple JSON */
+ TupleDescInitEntry(tupdesc, ++i, "key_tuple", JSONOID, -1, 0);
+
+ /* 10: remote_tuple JSON */
+ TupleDescInitEntry(tupdesc, ++i, "remote_tuple", JSONOID, -1, 0);
+
+ /* 11: local_conflicts JSON[] (Array of JSON) */
+ TupleDescInitEntry(tupdesc, ++i, "local_conflicts", get_array_type(JSONOID), -1, 0);
+
+ /* Bless the tuple descriptor */
+ return BlessTupleDesc(tupdesc);
+}
+
+/*
+ * Create conflict log table.
+ *
+ * The subscription owner becomes the owner of this table and has all
+ * privileges on it.
+ */
+static void
+create_conflict_log_table(Oid namespaceId, char *conflictrel, Oid subid)
+{
+ TupleDesc tupdesc;
+
+ /* Report an error if the specified conflict log table already exists. */
+ if (OidIsValid(get_relname_relid(conflictrel, namespaceId)))
+ ereport(ERROR,
+ (errcode(ERRCODE_DUPLICATE_TABLE),
+ errmsg("cannot create conflict log table \"%s.%s\" because a table with that name already exists",
+ get_namespace_name(namespaceId), conflictrel),
+ errhint("Use a different name for the conflict log table or drop the existing table.")));
+
+ /* Build the tuple descriptor for the new table. */
+ tupdesc = create_conflict_log_table_tupdesc();
+
+ /* Create conflict log table. */
+ heap_create_with_catalog(conflictrel,
+ namespaceId,
+ 0,
+ InvalidOid,
+ InvalidOid,
+ InvalidOid,
+ GetUserId(),
+ HEAP_TABLE_AM_OID,
+ tupdesc,
+ NIL,
+ RELKIND_RELATION,
+ RELPERSISTENCE_PERMANENT,
+ false,
+ false,
+ ONCOMMIT_NOOP,
+ (Datum) 0,
+ false,
+ false,
+ false,
+ InvalidOid,
+ NULL);
+
+ /* Release tuple descriptor memory. */
+ FreeTupleDesc(tupdesc);
+}
+
+/*
+ * Drop the conflict log table.
+ */
+static void
+drop_conflict_log_table(Oid namespaceId, char *conflictrel)
+{
+ Oid relid;
+ ObjectAddress object;
+
+ relid = get_relname_relid(conflictrel, namespaceId);
+
+ /* Return quietly if the table does not exist (e.g., user dropped it) */
+ if (!OidIsValid(relid))
+ return;
+
+ /* Create the object address for the table. */
+ ObjectAddressSet(object, RelationRelationId, relid);
+
+ /*
+ * Perform the deletion. Using DROP_CASCADE ensures the deletion of
+ * dependent objects.
+ */
+ performDeletion(&object, DROP_CASCADE, 0);
+}
+
+/*
+ * Check if the specified relation is used as a conflict log table by any
+ * subscription.
+ */
+bool
+IsConflictLogTable(Oid relid)
+{
+ Relation rel;
+ TableScanDesc scan;
+ HeapTuple tup;
+ bool is_clt = false;
+
+ rel = table_open(SubscriptionRelationId, AccessShareLock);
+ scan = table_beginscan_catalog(rel, 0, NULL);
+
+ while (HeapTupleIsValid(tup = heap_getnext(scan, ForwardScanDirection)))
+ {
+ Form_pg_subscription subform = (Form_pg_subscription) GETSTRUCT(tup);
+ Oid nspid;
+ char *relname;
+
+ relname = get_subscription_conflict_log_table(subform->oid, &nspid);
+ if (relname && relid == get_relname_relid(relname, nspid))
+ {
+ is_clt = true;
+ break;
+ }
+ }
+
+ table_endscan(scan);
+ table_close(rel, AccessShareLock);
+
+ return is_clt;
+}
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 16695592265..19a4bdbb1b9 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -15,13 +15,32 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
+#include "access/htup.h"
+#include "access/skey.h"
#include "access/tableam.h"
+#include "access/table.h"
+#include "catalog/pg_attribute.h"
+#include "catalog/indexing.h"
+#include "catalog/namespace.h"
+#include "catalog/pg_namespace.h"
+#include "catalog/pg_type.h"
#include "executor/executor.h"
+#include "executor/spi.h"
+#include "funcapi.h"
#include "pgstat.h"
#include "replication/conflict.h"
#include "replication/worker_internal.h"
#include "storage/lmgr.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/lsyscache.h"
+#include "utils/pg_lsn.h"
+#include "utils/array.h"
+#include "utils/jsonb.h"
+
+#define MAX_LOCAL_CONFLICT_INFO_ATTRS 4
+#define MAX_CONFLICT_ATTR_NUM 11
static const char *const ConflictTypeNames[] = {
[CT_INSERT_EXISTS] = "insert_exists",
@@ -50,8 +69,25 @@ static char *build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *localslot,
TupleTableSlot *remoteslot,
Oid indexoid);
+static void build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull);
static char *build_index_value_desc(EState *estate, Relation localrel,
TupleTableSlot *slot, Oid indexoid);
+static Datum tuple_table_slot_to_json_datum(TupleTableSlot *slot);
+static Datum tuple_table_slot_to_ri_json_datum(EState *estate,
+ Relation localrel,
+ Oid replica_index,
+ TupleTableSlot *slot);
+static TupleDesc build_conflict_tupledesc(void);
+static Datum build_local_conflicts_json_array(List *conflicttuples);
+static void prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot);
/*
* Get the xmin and commit timestamp data (origin and timestamp) associated
@@ -106,6 +142,7 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
TupleTableSlot *remoteslot, List *conflicttuples)
{
Relation localrel = relinfo->ri_RelationDesc;
+ Relation conflictlogrel = GetConflictLogTableRel();
StringInfoData err_detail;
initStringInfo(&err_detail);
@@ -120,6 +157,37 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
conflicttuple->ts,
&err_detail);
+ /* Insert conflict details to conflict log table. */
+ if (conflictlogrel)
+ {
+ if (ValidateConflictLogTable(conflictlogrel))
+ {
+ /*
+ * Prepare the conflict log tuple. If the error level is below
+ * ERROR, insert it immediately. Otherwise, defer the insertion to
+ * a new transaction after the current one aborts, ensuring the
+ * insertion of the log tuple is not rolled back.
+ */
+ prepare_conflict_log_tuple(estate,
+ relinfo->ri_RelationDesc,
+ conflictlogrel,
+ type,
+ searchslot,
+ conflicttuples,
+ remoteslot);
+ if (elevel < ERROR)
+ InsertConflictLogTuple(conflictlogrel);
+ }
+ else
+ ereport(WARNING,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("Conflict log table \"%s.%s\" structure changed, skipping insertion",
+ get_namespace_name(RelationGetNamespace(conflictlogrel)),
+ RelationGetRelationName(conflictlogrel)));
+
+ table_close(conflictlogrel, RowExclusiveLock);
+ }
+
pgstat_report_subscription_conflict(MySubscription->oid, type);
ereport(elevel,
@@ -162,6 +230,175 @@ InitConflictIndexes(ResultRelInfo *relInfo)
relInfo->ri_onConflictArbiterIndexes = uniqueIndexes;
}
+/*
+ * GetConflictLogTableRel
+ *
+ * Get the information of the specific conflict log table defined in
+ * pg_subscription and opens the relation for insertion. The caller is
+ * responsible for closing the returned relation handle.
+ */
+Relation
+GetConflictLogTableRel(void)
+{
+ Oid nspid;
+ Oid conflictlogrelid;
+ Relation conflictlogrel = NULL;
+ char *conflictlogtable;
+
+ /* If conflict log table is not set for the subscription just return. */
+ conflictlogtable = get_subscription_conflict_log_table(
+ MyLogicalRepWorker->subid, &nspid);
+ if (conflictlogtable == NULL)
+ return NULL;
+
+ conflictlogrelid = get_relname_relid(conflictlogtable, nspid);
+ if (OidIsValid(conflictlogrelid))
+ conflictlogrel = table_open(conflictlogrelid, RowExclusiveLock);
+
+ /* Conflict log table is dropped or not accessible. */
+ if (conflictlogrel == NULL)
+ ereport(WARNING,
+ (errcode(ERRCODE_UNDEFINED_TABLE),
+ errmsg("conflict log table \"%s.%s\" does not exist",
+ get_namespace_name(nspid), conflictlogtable)));
+
+ pfree(conflictlogtable);
+
+ return conflictlogrel;
+}
+
+/*
+ * InsertConflictLogTuple
+ *
+ * Insert conflict log tuple into the conflict log table. It uses
+ * HEAP_INSERT_NO_LOGICAL to explicitly block logical decoding
+ * of the tuple inserted into the conflict log table.
+ */
+void
+InsertConflictLogTuple(Relation conflictlogrel)
+{
+ int options = HEAP_INSERT_NO_LOGICAL;
+
+ /* A valid tuple must be prepared and stored in MyLogicalRepWorker. */
+ Assert(MyLogicalRepWorker->conflict_log_tuple != NULL);
+
+ heap_insert(conflictlogrel, MyLogicalRepWorker->conflict_log_tuple,
+ GetCurrentCommandId(true), options, NULL);
+
+ /* Free conflict log tuple. */
+ heap_freetuple(MyLogicalRepWorker->conflict_log_tuple);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+}
+
+/*
+ * ValidateConflictLogTable - Validate conflict log table
+ *
+ * Validate whether the conflict log table is still suitable for considering as
+ * conflict log table.
+ */
+bool
+ValidateConflictLogTable(Relation rel)
+{
+ Relation pg_attribute;
+ HeapTuple atup;
+ ScanKeyData scankey;
+ SysScanDesc scan;
+ Form_pg_attribute attForm;
+ int attcnt = 0;
+ bool tbl_ok = true;
+
+ /*
+ * Check whether the table definition conflictlogrel) including its column
+ * names, data types, and column ordering meets the requirements for
+ * conflict log table
+ */
+ pg_attribute = table_open(AttributeRelationId, AccessShareLock);
+ ScanKeyInit(&scankey,
+ Anum_pg_attribute_attrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationGetRelid(rel)));
+
+ scan = systable_beginscan(pg_attribute, AttributeRelidNumIndexId, true,
+ SnapshotSelf, 1, &scankey);
+ while (HeapTupleIsValid(atup = systable_getnext(scan)) && tbl_ok)
+ {
+ attForm = (Form_pg_attribute) GETSTRUCT(atup);
+
+ if (attForm->attnum < 1 || attForm->attisdropped)
+ continue;
+
+ attcnt++;
+ switch (attForm->attnum)
+ {
+ case 1:
+ if (attForm->atttypid != OIDOID ||
+ strcmp(NameStr(attForm->attname), "relid") != 0)
+ tbl_ok = false;
+ break;
+ case 2:
+ if (attForm->atttypid != TEXTOID ||
+ strcmp(NameStr(attForm->attname), "schemaname") != 0)
+ tbl_ok = false;
+ break;
+ case 3:
+ if (attForm->atttypid != TEXTOID ||
+ strcmp(NameStr(attForm->attname), "relname") != 0)
+ tbl_ok = false;
+ break;
+ case 4:
+ if (attForm->atttypid != TEXTOID ||
+ strcmp(NameStr(attForm->attname), "conflict_type") != 0)
+ tbl_ok = false;
+ break;
+ case 5:
+ if (attForm->atttypid != XIDOID ||
+ strcmp(NameStr(attForm->attname), "remote_xid") != 0)
+ tbl_ok = false;
+ break;
+ case 6:
+ if (attForm->atttypid != LSNOID ||
+ strcmp(NameStr(attForm->attname), "remote_commit_lsn") != 0)
+ tbl_ok = false;
+ break;
+ case 7:
+ if (attForm->atttypid != TIMESTAMPTZOID ||
+ strcmp(NameStr(attForm->attname), "remote_commit_ts") != 0)
+ tbl_ok = false;
+ break;
+ case 8:
+ if (attForm->atttypid != TEXTOID ||
+ strcmp(NameStr(attForm->attname), "remote_origin") != 0)
+ tbl_ok = false;
+ break;
+ case 9:
+ if (attForm->atttypid != JSONOID ||
+ strcmp(NameStr(attForm->attname), "key_tuple") != 0)
+ tbl_ok = false;
+ break;
+ case 10:
+ if (attForm->atttypid != JSONOID ||
+ strcmp(NameStr(attForm->attname), "remote_tuple") != 0)
+ tbl_ok = false;
+ break;
+ case 11:
+ if (attForm->atttypid != JSONARRAYOID ||
+ strcmp(NameStr(attForm->attname), "local_conflicts") != 0)
+ tbl_ok = false;
+ break;
+ default:
+ tbl_ok = false;
+ break;
+ }
+ }
+ systable_endscan(scan);
+ table_close(pg_attribute, AccessShareLock);
+
+ if (attcnt != MAX_CONFLICT_ATTR_NUM || !tbl_ok)
+ return false;
+
+ return true;
+}
+
/*
* Add SQLSTATE error code to the current conflict report.
*/
@@ -472,6 +709,40 @@ build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
return tuple_value.data;
}
+/*
+ * Helper function to extract the "raw" index key Datums and their null flags
+ * from a TupleTableSlot, given an already open index descriptor.
+ * This is the reusable core logic.
+ */
+static void
+build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull)
+{
+ TupleTableSlot *tableslot = slot;
+
+ /*
+ * If the slot is a virtual slot, copy it into a heap tuple slot as
+ * FormIndexDatum only works with heap tuple slots.
+ */
+ if (TTS_IS_VIRTUAL(slot))
+ {
+ /* Slot is created within the EState's tuple table */
+ tableslot = table_slot_create(localrel, &estate->es_tupleTable);
+ tableslot = ExecCopySlot(tableslot, slot);
+ }
+
+ /*
+ * Initialize ecxt_scantuple for potential use in FormIndexDatum
+ */
+ GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+
+ /* Form the index datums */
+ FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values,
+ isnull);
+}
+
/*
* Helper functions to construct a string describing the contents of an index
* entry. See BuildIndexValueDescription for details.
@@ -487,41 +758,310 @@ build_index_value_desc(EState *estate, Relation localrel, TupleTableSlot *slot,
Relation indexDesc;
Datum values[INDEX_MAX_KEYS];
bool isnull[INDEX_MAX_KEYS];
- TupleTableSlot *tableslot = slot;
- if (!tableslot)
+ if (!slot)
return NULL;
Assert(CheckRelationOidLockedByMe(indexoid, RowExclusiveLock, true));
indexDesc = index_open(indexoid, NoLock);
- /*
- * If the slot is a virtual slot, copy it into a heap tuple slot as
- * FormIndexDatum only works with heap tuple slots.
- */
- if (TTS_IS_VIRTUAL(slot))
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+
+ index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+
+ index_close(indexDesc, NoLock);
+
+ return index_value;
+}
+
+/*
+ * tuple_table_slot_to_json_datum
+ *
+ * Helper function to convert a TupleTableSlot to Jsonb.
+ */
+static Datum
+tuple_table_slot_to_json_datum(TupleTableSlot *slot)
+{
+ HeapTuple tuple;
+ Datum datum;
+ Datum json;
+
+ Assert(slot != NULL);
+
+ tuple = ExecCopySlotHeapTuple(slot);
+ datum = heap_copy_tuple_as_datum(tuple, slot->tts_tupleDescriptor);
+
+ json = DirectFunctionCall1(row_to_json, datum);
+ heap_freetuple(tuple);
+
+ return json;
+}
+
+/*
+ * tuple_table_slot_to_ri_json_datum
+ *
+ * Fetch replica identity key from the tuple table slot and convert into a
+ * jsonb datum.
+ */
+static Datum
+tuple_table_slot_to_ri_json_datum(EState *estate, Relation localrel,
+ Oid replica_index, TupleTableSlot *slot)
+{
+ Relation indexDesc;
+ Datum values[INDEX_MAX_KEYS];
+ bool isnull[INDEX_MAX_KEYS];
+ HeapTuple tuple;
+ TupleDesc tupdesc;
+ Datum datum;
+
+ Assert(slot != NULL);
+
+ Assert(CheckRelationOidLockedByMe(replica_index, RowExclusiveLock, true));
+
+ indexDesc = index_open(replica_index, NoLock);
+
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+ tupdesc = RelationGetDescr(indexDesc);
+
+ /* Bless the tupdesc so it can be looked up by row_to_json. */
+ BlessTupleDesc(tupdesc);
+
+ /* Form the replica identity tuple. */
+ tuple = heap_form_tuple(tupdesc, values, isnull);
+ datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ index_close(indexDesc, NoLock);
+
+ /* Convert to a JSONB datum. */
+ return DirectFunctionCall1(row_to_json, datum);
+}
+
+/*
+ * Initialize the tuple descriptor for local conflict info.
+ */
+static TupleDesc
+build_conflict_tupledesc(void)
+{
+ TupleDesc tupdesc;
+ int attno = 1;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ TupleDescInitEntry(tupdesc, (AttrNumber) attno++, "xid",
+ XIDOID, -1, 0);
+ TupleDescInitEntry(tupdesc, (AttrNumber) attno++, "commit_ts",
+ TIMESTAMPTZOID, -1, 0);
+ TupleDescInitEntry(tupdesc, (AttrNumber) attno++, "origin",
+ TEXTOID, -1, 0);
+ TupleDescInitEntry(tupdesc, (AttrNumber) attno, "tuple",
+ JSONOID, -1, 0);
+
+ BlessTupleDesc(tupdesc);
+
+ Assert(attno == MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ return tupdesc;
+}
+
+/*
+ * Builds the local conflicts JSONB array column from the list of
+ * ConflictTupleInfo objects.
+ *
+ * Example output structure:
+ * [ { "xid": "1001", "commit_ts": "...", "origin": "...", "tuple": {...} }, ... ]
+ */
+static Datum
+build_local_conflicts_json_array(List *conflicttuples)
+{
+ ListCell *lc;
+ List *json_datums = NIL; /* List to hold the row_to_json results (type json) */
+ Datum *json_datum_array;
+ bool *json_null_array;
+ Datum json_array_datum;
+ int num_conflicts;
+ int i;
+ int16 typlen;
+ bool typbyval;
+ char typalign;
+ TupleDesc tupdesc;
+
+ /* Build local conflicts tuple descriptor. */
+ tupdesc = build_conflict_tupledesc();
+
+ /* Process local conflict tuple list and prepare an array of JSON. */
+ foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
{
- tableslot = table_slot_create(localrel, &estate->es_tupleTable);
- tableslot = ExecCopySlot(tableslot, slot);
+ Datum values[MAX_LOCAL_CONFLICT_INFO_ATTRS];
+ bool nulls[MAX_LOCAL_CONFLICT_INFO_ATTRS];
+ char *origin_name = NULL;
+ HeapTuple tuple;
+ Datum json_datum;
+ int attno;
+
+ memset(values, 0, sizeof(Datum) * MAX_LOCAL_CONFLICT_INFO_ATTRS);
+ memset(nulls, 0, sizeof(bool) * MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ attno = 0;
+ values[attno++] = TransactionIdGetDatum(conflicttuple->xmin);
+
+ if (conflicttuple->ts)
+ values[attno++] = TimestampTzGetDatum(conflicttuple->ts);
+ else
+ nulls[attno++] = true;
+
+ if (conflicttuple->origin != InvalidRepOriginId)
+ replorigin_by_oid(conflicttuple->origin, true, &origin_name);
+
+ /* Store empty string if origin name for the tuple is NULL. */
+ if (origin_name != NULL)
+ values[attno++] = CStringGetTextDatum(origin_name);
+ else
+ nulls[attno++] = true;
+
+ /* Convert conflicting tuple to JSON datum. */
+ if (conflicttuple->slot)
+ values[attno] = tuple_table_slot_to_json_datum(conflicttuple->slot);
+ else
+ nulls[attno] = true;
+
+ Assert(attno + 1 == MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ tuple = heap_form_tuple(tupdesc, values, nulls);
+
+ json_datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ /*
+ * Build the higher level JSON datum in format described in function
+ * header.
+ */
+ json_datum = DirectFunctionCall1(row_to_json, json_datum);
+
+ /* Done with the temporary tuple. */
+ heap_freetuple(tuple);
+
+ /* Add to the array element. */
+ json_datums = lappend(json_datums, (void *) json_datum);
}
- /*
- * Initialize ecxt_scantuple for potential use in FormIndexDatum when
- * index expressions are present.
- */
- GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+ num_conflicts = list_length(json_datums);
- /*
- * The values/nulls arrays passed to BuildIndexValueDescription should be
- * the results of FormIndexDatum, which are the "raw" input to the index
- * AM.
- */
- FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values, isnull);
+ json_datum_array = (Datum *) palloc(num_conflicts * sizeof(Datum));
+ json_null_array = (bool *) palloc0(num_conflicts * sizeof(bool));
- index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+ i = 0;
+ foreach(lc, json_datums)
+ {
+ json_datum_array[i] = (Datum) lfirst(lc);
+ i++;
+ }
- index_close(indexDesc, NoLock);
+ get_typlenbyvalalign(JSONOID, &typlen, &typbyval, &typalign);
- return index_value;
+ /* Construct the json[] array Datum */
+ json_array_datum = PointerGetDatum(construct_array(json_datum_array,
+ num_conflicts,
+ JSONOID,
+ typlen,
+ typbyval,
+ typalign));
+ pfree(json_datum_array);
+ pfree(json_null_array);
+
+ return json_array_datum;
+}
+
+/*
+ * prepare_conflict_log_tuple
+ *
+ * This routine prepares a tuple detailing a conflict encountered during
+ * logical replication. The prepared tuple will be stored in
+ * MyLogicalRepWorker->conflict_log_tuple which should be inserted into the
+ * conflict log table by calling InsertConflictLogTuple.
+ */
+static void
+prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot)
+{
+ Datum values[MAX_CONFLICT_ATTR_NUM];
+ bool nulls[MAX_CONFLICT_ATTR_NUM];
+ int attno;
+ char *remote_origin = NULL;
+ MemoryContext oldctx;
+
+ Assert(MyLogicalRepWorker->conflict_log_tuple == NULL);
+
+ /* Initialize values and nulls arrays. */
+ memset(values, 0, sizeof(Datum) * MAX_CONFLICT_ATTR_NUM);
+ memset(nulls, 0, sizeof(bool) * MAX_CONFLICT_ATTR_NUM);
+
+ /* Populate the values and nulls arrays. */
+ attno = 0;
+ values[attno++] = ObjectIdGetDatum(RelationGetRelid(rel));
+
+ values[attno++] =
+ CStringGetTextDatum(get_namespace_name(RelationGetNamespace(rel)));
+
+ values[attno++] = CStringGetTextDatum(RelationGetRelationName(rel));
+
+ values[attno++] = CStringGetTextDatum(ConflictTypeNames[conflict_type]);
+
+ if (TransactionIdIsValid(remote_xid))
+ values[attno++] = TransactionIdGetDatum(remote_xid);
+ else
+ nulls[attno++] = true;
+
+ values[attno++] = LSNGetDatum(remote_final_lsn);
+
+ if (remote_commit_ts > 0)
+ values[attno++] = TimestampTzGetDatum(remote_commit_ts);
+ else
+ nulls[attno++] = true;
+
+ if (replorigin_session_origin != InvalidRepOriginId)
+ replorigin_by_oid(replorigin_session_origin, true, &remote_origin);
+
+ if (remote_origin != NULL)
+ values[attno++] = CStringGetTextDatum(remote_origin);
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(searchslot))
+ {
+ Oid replica_index = GetRelationIdentityOrPK(rel);
+
+ /*
+ * If the table has a valid replica identity index, build the index
+ * json datum from key value. Otherwise, construct it from the complete
+ * tuple in REPLICA IDENTITY FULL cases.
+ */
+ if (OidIsValid(replica_index))
+ values[attno++] = tuple_table_slot_to_ri_json_datum(estate, rel,
+ replica_index,
+ searchslot);
+ else
+ values[attno++] = tuple_table_slot_to_json_datum(searchslot);
+ }
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(remoteslot))
+ values[attno++] = tuple_table_slot_to_json_datum(remoteslot);
+ else
+ nulls[attno++] = true;
+
+ values[attno] = build_local_conflicts_json_array(conflicttuples);
+
+ Assert(attno + 1 == MAX_CONFLICT_ATTR_NUM);
+
+ oldctx = MemoryContextSwitchTo(ApplyContext);
+ MyLogicalRepWorker->conflict_log_tuple =
+ heap_form_tuple(RelationGetDescr(conflictlogrel), values, nulls);
+ MemoryContextSwitchTo(oldctx);
}
diff --git a/src/backend/replication/logical/launcher.c b/src/backend/replication/logical/launcher.c
index fdf1ccad462..2364146ca36 100644
--- a/src/backend/replication/logical/launcher.c
+++ b/src/backend/replication/logical/launcher.c
@@ -477,6 +477,7 @@ retry:
worker->oldest_nonremovable_xid = retain_dead_tuples
? MyReplicationSlot->data.xmin
: InvalidTransactionId;
+ worker->conflict_log_tuple = NULL;
worker->last_lsn = InvalidXLogRecPtr;
TIMESTAMP_NOBEGIN(worker->last_send_time);
TIMESTAMP_NOBEGIN(worker->last_recv_time);
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 93970c6af29..8055cd57a14 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -482,7 +482,9 @@ static bool MySubscriptionValid = false;
static List *on_commit_wakeup_workers_subids = NIL;
bool in_remote_transaction = false;
-static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+TransactionId remote_xid = InvalidTransactionId;
+TimestampTz remote_commit_ts = 0;
/* fields valid only when processing streamed transaction */
static bool in_streamed_transaction = false;
@@ -1219,6 +1221,8 @@ apply_handle_begin(StringInfo s)
set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
remote_final_lsn = begin_data.final_lsn;
+ remote_commit_ts = begin_data.committime;
+ remote_xid = begin_data.xid;
maybe_start_skipping_changes(begin_data.final_lsn);
@@ -1745,6 +1749,10 @@ apply_handle_stream_start(StringInfo s)
/* extract XID of the top-level transaction */
stream_xid = logicalrep_read_stream_start(s, &first_segment);
+ remote_xid = stream_xid;
+ remote_final_lsn = InvalidXLogRecPtr;
+ remote_commit_ts = 0;
+
if (!TransactionIdIsValid(stream_xid))
ereport(ERROR,
(errcode(ERRCODE_PROTOCOL_VIOLATION),
@@ -5609,6 +5617,33 @@ start_apply(XLogRecPtr origin_startpos)
pgstat_report_subscription_error(MySubscription->oid,
MyLogicalRepWorker->type);
+ /*
+ * Insert any pending conflict log tuple under a new transaction.
+ */
+ if (MyLogicalRepWorker->conflict_log_tuple != NULL)
+ {
+ Relation conflictlogrel;
+
+ StartTransactionCommand();
+ PushActiveSnapshot(GetTransactionSnapshot());
+
+ /* Open conflict log table and insert the tuple. */
+ conflictlogrel = GetConflictLogTableRel();
+ if (ValidateConflictLogTable(conflictlogrel))
+ InsertConflictLogTuple(conflictlogrel);
+ else
+ ereport(WARNING,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("Conflict log table \"%s.%s\" structure changed, skipping insertion",
+ get_namespace_name(RelationGetNamespace(conflictlogrel)),
+ RelationGetRelationName(conflictlogrel)));
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+ table_close(conflictlogrel, RowExclusiveLock);
+
+ PopActiveSnapshot();
+ CommitTransactionCommand();
+ }
+
PG_RE_THROW();
}
}
diff --git a/src/backend/utils/cache/lsyscache.c b/src/backend/utils/cache/lsyscache.c
index fa7cd7e06a7..12db6676406 100644
--- a/src/backend/utils/cache/lsyscache.c
+++ b/src/backend/utils/cache/lsyscache.c
@@ -3881,3 +3881,41 @@ get_subscription_name(Oid subid, bool missing_ok)
return subname;
}
+
+/*
+ * get_subscription_conflict_log_table
+ *
+ * Get conflict log table name and namespace id from subscription.
+ */
+char *
+get_subscription_conflict_log_table(Oid subid, Oid *nspid)
+{
+ HeapTuple tup;
+ Datum datum;
+ bool isnull;
+ char *relname = NULL;
+ Form_pg_subscription subform;
+
+ *nspid = InvalidOid;
+
+ tup = SearchSysCache1(SUBSCRIPTIONOID, ObjectIdGetDatum(subid));
+
+ if (!HeapTupleIsValid(tup))
+ return NULL;
+
+ subform = (Form_pg_subscription) GETSTRUCT(tup);
+
+ /* Get conflict log table name. */
+ datum = SysCacheGetAttr(SUBSCRIPTIONOID,
+ tup,
+ Anum_pg_subscription_subconflictlogtable,
+ &isnull);
+ if (!isnull)
+ {
+ *nspid = subform->subconflictlognspid;
+ relname = pstrdup(TextDatumGetCString(datum));
+ }
+
+ ReleaseSysCache(tup);
+ return relname;
+}
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index 36f24502842..c18ec248e5d 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -6806,7 +6806,7 @@ describeSubscriptions(const char *pattern, bool verbose)
printQueryOpt myopt = pset.popt;
static const bool translate_columns[] = {false, false, false, false,
false, false, false, false, false, false, false, false, false, false,
- false, false, false, false};
+ false, false, false, false, false};
if (pset.sversion < 100000)
{
@@ -6900,6 +6900,12 @@ describeSubscriptions(const char *pattern, bool verbose)
appendPQExpBuffer(&buf,
", subskiplsn AS \"%s\"\n",
gettext_noop("Skip LSN"));
+
+ /* Conflict log table is only supported in v19 and higher */
+ if (pset.sversion >= 190000)
+ appendPQExpBuffer(&buf,
+ ", subconflictlogtable AS \"%s\"\n",
+ gettext_noop("Conflict log table"));
}
/* Only display subscriptions in current database. */
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 20d7a65c614..6ab4ab8857d 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -2344,8 +2344,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("(", "PUBLICATION");
/* ALTER SUBSCRIPTION <name> SET ( */
else if (Matches("ALTER", "SUBSCRIPTION", MatchAny, MatchAnyN, "SET", "("))
- COMPLETE_WITH("binary", "disable_on_error", "failover",
- "max_retention_duration", "origin",
+ COMPLETE_WITH("binary", "conflict_log_table", "disable_on_error",
+ "failover", "max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
"synchronous_commit", "two_phase");
@@ -3814,8 +3814,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("WITH (");
/* Complete "CREATE SUBSCRIPTION <name> ... WITH ( <opt>" */
else if (Matches("CREATE", "SUBSCRIPTION", MatchAnyN, "WITH", "("))
- COMPLETE_WITH("binary", "connect", "copy_data", "create_slot",
- "disable_on_error", "enabled", "failover",
+ COMPLETE_WITH("binary", "conflict_log_table", "connect", "copy_data",
+ "create_slot", "disable_on_error", "enabled", "failover",
"max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index 55cb9b1eefa..f4526c15ec3 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -80,6 +80,8 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
bool subretaindeadtuples; /* True if dead tuples useful for
* conflict detection are retained */
+ Oid subconflictlognspid; /* Namespace Oid in which the conflict
+ * log table is created. */
int32 submaxretention; /* The maximum duration (in milliseconds)
* for which information useful for
@@ -105,6 +107,9 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
/* Only publish data originating from the specified origin */
text suborigin BKI_DEFAULT(LOGICALREP_ORIGIN_ANY);
+
+ /* Conflict log table name if specified */
+ text subconflictlogtable;
#endif
} FormData_pg_subscription;
diff --git a/src/include/commands/subscriptioncmds.h b/src/include/commands/subscriptioncmds.h
index fb4e26a51a4..6c062b0991f 100644
--- a/src/include/commands/subscriptioncmds.h
+++ b/src/include/commands/subscriptioncmds.h
@@ -36,4 +36,6 @@ extern void CheckSubDeadTupleRetention(bool check_guc, bool sub_disabled,
bool retention_active,
bool max_retention_set);
+extern bool IsConflictLogTable(Oid relid);
+
#endif /* SUBSCRIPTIONCMDS_H */
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index c8fbf9e51b8..018f013e15d 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -9,9 +9,11 @@
#ifndef CONFLICT_H
#define CONFLICT_H
+#include "access/htup.h"
#include "access/xlogdefs.h"
#include "nodes/pg_list.h"
#include "utils/timestamp.h"
+#include "utils/relcache.h"
/* Avoid including execnodes.h here */
typedef struct EState EState;
@@ -89,4 +91,7 @@ extern void ReportApplyConflict(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *remoteslot,
List *conflicttuples);
extern void InitConflictIndexes(ResultRelInfo *relInfo);
+extern Relation GetConflictLogTableRel(void);
+extern void InsertConflictLogTuple(Relation conflictlogrel);
+extern bool ValidateConflictLogTable(Relation rel);
#endif
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index f081619f151..711c04c7297 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -101,6 +101,9 @@ typedef struct LogicalRepWorker
*/
TransactionId oldest_nonremovable_xid;
+ /* A conflict log tuple that is prepared but not yet inserted. */
+ HeapTuple conflict_log_tuple;
+
/* Stats. */
XLogRecPtr last_lsn;
TimestampTz last_send_time;
@@ -256,6 +259,10 @@ extern PGDLLIMPORT bool InitializingApplyWorker;
extern PGDLLIMPORT List *table_states_not_ready;
+extern XLogRecPtr remote_final_lsn;
+extern TimestampTz remote_commit_ts;
+extern TransactionId remote_xid;
+
extern void logicalrep_worker_attach(int slot);
extern LogicalRepWorker *logicalrep_worker_find(LogicalRepWorkerType wtype,
Oid subid, Oid relid,
diff --git a/src/include/utils/lsyscache.h b/src/include/utils/lsyscache.h
index 50fb149e9ac..3bebf04bf51 100644
--- a/src/include/utils/lsyscache.h
+++ b/src/include/utils/lsyscache.h
@@ -210,6 +210,7 @@ extern Oid get_publication_oid(const char *pubname, bool missing_ok);
extern char *get_publication_name(Oid pubid, bool missing_ok);
extern Oid get_subscription_oid(const char *subname, bool missing_ok);
extern char *get_subscription_name(Oid subid, bool missing_ok);
+extern char *get_subscription_conflict_log_table(Oid subid, Oid *nspid);
#define type_is_array(typid) (get_element_type(typid) != InvalidOid)
/* type_is_array_domain accepts both plain arrays and domains over arrays */
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index 327d1e7731f..bab2d0ea954 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -116,18 +116,18 @@ CREATE SUBSCRIPTION regress_testsub4 CONNECTION 'dbname=regress_doesnotexist' PU
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub4 SET (origin = any);
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
DROP SUBSCRIPTION regress_testsub3;
@@ -145,10 +145,10 @@ ALTER SUBSCRIPTION regress_testsub CONNECTION 'foobar';
ERROR: invalid connection string syntax: missing "=" after "foobar" in connection info string
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET PUBLICATION testpub2, testpub3 WITH (refresh = false);
@@ -157,10 +157,10 @@ ALTER SUBSCRIPTION regress_testsub SET (slot_name = 'newname');
ALTER SUBSCRIPTION regress_testsub SET (password_required = false);
ALTER SUBSCRIPTION regress_testsub SET (run_as_owner = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (password_required = true);
@@ -176,10 +176,10 @@ ERROR: unrecognized subscription parameter: "create_slot"
-- ok
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/12345');
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345 |
(1 row)
-- ok - with lsn = NONE
@@ -188,10 +188,10 @@ ALTER SUBSCRIPTION regress_testsub SKIP (lsn = NONE);
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/0');
ERROR: invalid WAL location (LSN): 0/0
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 |
(1 row)
BEGIN;
@@ -223,10 +223,10 @@ ALTER SUBSCRIPTION regress_testsub_foo SET (synchronous_commit = foobar);
ERROR: invalid value for parameter "synchronous_commit": "foobar"
HINT: Available values: local, remote_write, remote_apply, on, off.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
----------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+---------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000 |
(1 row)
-- rename back to keep the rest simple
@@ -255,19 +255,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (binary = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -279,27 +279,27 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = parallel);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- fail - publication already exists
@@ -314,10 +314,10 @@ ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refr
ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refresh = false);
ERROR: publication "testpub1" is already in subscription "regress_testsub"
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- fail - publication used more than once
@@ -332,10 +332,10 @@ ERROR: publication "testpub3" is not in subscription "regress_testsub"
-- ok - delete publications
ALTER SUBSCRIPTION regress_testsub DROP PUBLICATION testpub1, testpub2 WITH (refresh = false);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -371,19 +371,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- we can alter streaming when two_phase enabled
ALTER SUBSCRIPTION regress_testsub SET (streaming = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -393,10 +393,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -409,18 +409,18 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (disable_on_error = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -433,10 +433,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -450,19 +450,19 @@ NOTICE: max_retention_duration is ineffective when retain_dead_tuples is disabl
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- ok
ALTER SUBSCRIPTION regress_testsub SET (max_retention_duration = 0);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -517,7 +517,191 @@ COMMIT;
-- ok, owning it is enough for this stuff
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG TABLE TESTS
+--
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+-- fail - conflict_log_table specified when table already exists
+CREATE TABLE public.regress_conflict_log_temp (id int);
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log_temp');
+ERROR: cannot create conflict log table "public.regress_conflict_log_temp" because a table with that name already exists
+HINT: Use a different name for the conflict log table or drop the existing table.
+DROP TABLE public.regress_conflict_log_temp;
+-- ok - conflict_log_table creation with CREATE SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- check metadata in pg_subscription
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname | subconflictlogtable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test1 | regress_conflict_log1 | t
+(1 row)
+
+-- check if the table exists and has the correct schema (11 columns)
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attnum > 0;
+ count
+-------
+ 11
+(1 row)
+
+-- check a specific column type (e.g., key_tuple should be JSON)
+SELECT format_type(atttypid, atttypmod) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attname = 'key_tuple';
+ format_type
+-------------
+ json
+(1 row)
+
+\dRs+
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+-----------------------
+ regress_conflict_test1 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | regress_conflict_log1
+(1 row)
+
+-- ok - adding conflict_log_table with ALTER SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log2');
+-- check metadata after ALTER
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogtable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test2 | regress_conflict_log2 | t
+(1 row)
+
+-- ok - change the conflict log table name for an existing subscription that already had one
+CREATE SCHEMA clt;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'clt.regress_conflict_log3');
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogtable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test2 | regress_conflict_log3 | f
+(1 row)
+
+\dRs+
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+-----------------------
+ regress_conflict_test1 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | regress_conflict_log1
+ regress_conflict_test2 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | regress_conflict_log3
+(2 rows)
+
+-- check the new table was created and the old table was dropped
+SELECT count(*) FROM pg_class WHERE relname = 'regress_conflict_log2';
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'clt.regress_conflict_log3'::regclass AND attnum > 0;
+ count
+-------
+ 11
+(1 row)
+
+-- ok (NOTICE) - set conflict_log_table to one already used by this subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'clt.regress_conflict_log3');
+NOTICE: "clt.regress_conflict_log3" is already in use as the conflict log table for this subscription
+-- fail - try to publish the conflict_log_table
+CREATE PUBLICATION pub FOR TABLE clt.regress_conflict_log3;
+ERROR: cannot add relation "clt.regress_conflict_log3" to publication
+DETAIL: This operation is not supported for conflict log tables.
+-- suppress warning that depends on wal_level
+SET client_min_messages = 'ERROR';
+-- ok - conflict_log_table should not be published with ALL TABLE
+CREATE PUBLICATION pub FOR TABLES IN SCHEMA clt;
+SELECT * FROM pg_publication_tables WHERE pubname = 'pub';
+ pubname | schemaname | tablename | attnames | rowfilter
+---------+------------+-----------+----------+-----------
+(0 rows)
+
+\dt+ clt.regress_conflict_log3
+ List of tables
+ Schema | Name | Type | Owner | Persistence | Size | Description
+--------+-----------------------+-------+---------------------------+-------------+---------+-------------
+ clt | regress_conflict_log3 | table | regress_subscription_user | permanent | 0 bytes |
+(1 row)
+
+DROP PUBLICATION pub;
+-- fail - set conflict_log_table to one already used by a different subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log1');
+ERROR: cannot create conflict log table "public.regress_conflict_log1" because a table with that name already exists
+HINT: Use a different name for the conflict log table or drop the existing table.
+-- ok - dropping subscription also drops the log table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the table was dropped
+SELECT to_regclass('public.regress_conflict_log1');
+ to_regclass
+-------------
+
+(1 row)
+
+-- ok - dropping subscription when the log table was manually dropped first
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+DROP TABLE public.regress_conflict_log1;
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the subscription was dropped successfully
+SELECT subname FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname
+---------
+(0 rows)
+
+-- ok - create subscription with conflict_log_table = NONE
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = NONE);
+SELECT subname, subconflictlogtable FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogtable
+------------------------+-----------------------
+ regress_conflict_test2 | regress_conflict_log3
+(1 row)
+
+--ok - alter subscription with valid conflict log table name
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log1');
+SELECT subname, subconflictlogtable FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogtable
+------------------------+-----------------------
+ regress_conflict_test2 | regress_conflict_log1
+(1 row)
+
+--ok - pg_relation_is_publishable should return false for conflict log table
+SELECT pg_relation_is_publishable('public.regress_conflict_log1');
+ pg_relation_is_publishable
+----------------------------
+ f
+(1 row)
+
+--ok - alter subscription with conflict log table = NONE
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = NONE);
+-- should return NULL, meaning the table was dropped
+SELECT to_regclass('public.regress_conflict_log1');
+ to_regclass
+-------------
+
+(1 row)
+
+SELECT subname, subconflictlogtable FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogtable
+------------------------+---------------------
+ regress_conflict_test2 |
+(1 row)
+
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
RESET SESSION AUTHORIZATION;
+DROP SCHEMA clt;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
DROP ROLE regress_subscription_user3;
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index ef0c298d2df..416847f081b 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -365,7 +365,115 @@ COMMIT;
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG TABLE TESTS
+--
+
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+
+-- fail - conflict_log_table specified when table already exists
+CREATE TABLE public.regress_conflict_log_temp (id int);
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log_temp');
+DROP TABLE public.regress_conflict_log_temp;
+
+-- ok - conflict_log_table creation with CREATE SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+
+-- check metadata in pg_subscription
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- check if the table exists and has the correct schema (11 columns)
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attnum > 0;
+
+-- check a specific column type (e.g., key_tuple should be JSON)
+SELECT format_type(atttypid, atttypmod) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attname = 'key_tuple';
+
+\dRs+
+
+-- ok - adding conflict_log_table with ALTER SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log2');
+
+-- check metadata after ALTER
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- ok - change the conflict log table name for an existing subscription that already had one
+CREATE SCHEMA clt;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'clt.regress_conflict_log3');
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+\dRs+
+
+-- check the new table was created and the old table was dropped
+SELECT count(*) FROM pg_class WHERE relname = 'regress_conflict_log2';
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'clt.regress_conflict_log3'::regclass AND attnum > 0;
+
+-- ok (NOTICE) - set conflict_log_table to one already used by this subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'clt.regress_conflict_log3');
+
+-- fail - try to publish the conflict_log_table
+CREATE PUBLICATION pub FOR TABLE clt.regress_conflict_log3;
+
+-- suppress warning that depends on wal_level
+SET client_min_messages = 'ERROR';
+
+-- ok - conflict_log_table should not be published with ALL TABLE
+CREATE PUBLICATION pub FOR TABLES IN SCHEMA clt;
+SELECT * FROM pg_publication_tables WHERE pubname = 'pub';
+\dt+ clt.regress_conflict_log3
+DROP PUBLICATION pub;
+
+-- fail - set conflict_log_table to one already used by a different subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log1');
+
+-- ok - dropping subscription also drops the log table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the table was dropped
+SELECT to_regclass('public.regress_conflict_log1');
+
+-- ok - dropping subscription when the log table was manually dropped first
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+DROP TABLE public.regress_conflict_log1;
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the subscription was dropped successfully
+SELECT subname FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- ok - create subscription with conflict_log_table = NONE
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = NONE);
+SELECT subname, subconflictlogtable FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+--ok - alter subscription with valid conflict log table name
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log1');
+SELECT subname, subconflictlogtable FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+--ok - pg_relation_is_publishable should return false for conflict log table
+SELECT pg_relation_is_publishable('public.regress_conflict_log1');
+
+--ok - alter subscription with conflict log table = NONE
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = NONE);
+
+-- should return NULL, meaning the table was dropped
+SELECT to_regclass('public.regress_conflict_log1');
+SELECT subname, subconflictlogtable FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+
RESET SESSION AUTHORIZATION;
+DROP SCHEMA clt;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
DROP ROLE regress_subscription_user3;
--
2.49.0
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-11 11:34 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: shveta malik @ 2025-12-11 11:34 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Tue, Dec 9, 2025 at 8:41 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> >
> Here is the updated version of patch
> What has changed
> 1. Table is created using create_heap_with_catalog() instead of SPI as
> suggested by Sawada-San and Amit Kapila.
> 2. Validated the table schema after acquiring the lock before
> preparing/inserting conflict tuples for defects raised by Vignesh.
> 3. Bug fixes raised by Shweta (segfault)
> 3. Comments from Peter (except exposing namespace in \dRs+, it's still pending.
>
Thanks for the patch.
I tested all conflict-types on this version, they (basic scenarios)
seem to work well. Except only that key-RI pending issue, other issues
seem to be addressed. I will start with code-review now.
Few observations:
1)
\dRs+ shows 'Conflict log table' without namespace, this could be
confusing if the same table exists in multiple schemas.
2)
When we do below:
alter subscription sub1 SET (conflict_log_table=clt2);
the previous conflict log table is dropped. Is this behavior
intentional and discussed/concluded earlier? It’s possible that a user
may want to create a new conflict log table for future events while
still retaining the old one for analysis. If the subscription itself
is dropped, then dropping the CLT makes sense, but I’m not sure this
behavior is intended for ALTER SUBSCRIPTION. I do understand that
once we unlink CLT from subscription, later even DROP subscription
cannot drop it, but user can always drop it when not needed.
If we plan to keep existing behavior, it should be clearly documented
in a CAUTION section, and the command should explicitly log the table
drop.
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-11 11:40 Dilip Kumar <dilipbalaut@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-12-11 11:40 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, Dec 11, 2025 at 5:04 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> On Tue, Dec 9, 2025 at 8:41 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > >
> > Here is the updated version of patch
> > What has changed
> > 1. Table is created using create_heap_with_catalog() instead of SPI as
> > suggested by Sawada-San and Amit Kapila.
> > 2. Validated the table schema after acquiring the lock before
> > preparing/inserting conflict tuples for defects raised by Vignesh.
> > 3. Bug fixes raised by Shweta (segfault)
> > 3. Comments from Peter (except exposing namespace in \dRs+, it's still pending.
> >
>
> Thanks for the patch.
> I tested all conflict-types on this version, they (basic scenarios)
> seem to work well. Except only that key-RI pending issue, other issues
> seem to be addressed. I will start with code-review now.
>
> Few observations:
>
> 1)
> \dRs+ shows 'Conflict log table' without namespace, this could be
> confusing if the same table exists in multiple schemas.
Yeah this is not yet fixed comments, will fix in next version.
> 2)
> When we do below:
> alter subscription sub1 SET (conflict_log_table=clt2);
>
> the previous conflict log table is dropped. Is this behavior
> intentional and discussed/concluded earlier? It’s possible that a user
> may want to create a new conflict log table for future events while
> still retaining the old one for analysis. If the subscription itself
> is dropped, then dropping the CLT makes sense, but I’m not sure this
> behavior is intended for ALTER SUBSCRIPTION. I do understand that
> once we unlink CLT from subscription, later even DROP subscription
> cannot drop it, but user can always drop it when not needed.
>
> If we plan to keep existing behavior, it should be clearly documented
> in a CAUTION section, and the command should explicitly log the table
> drop.
Yeah we discussed this behavior and the conclusion was we would
document this behavior and its user's responsibility to take necessary
backup of the conflict log table data if they are setting a new log
table or NONE for the subscription.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-11 12:26 Amit Kapila <amit.kapila16@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Amit Kapila @ 2025-12-11 12:26 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, Dec 11, 2025 at 5:10 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Thu, Dec 11, 2025 at 5:04 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> > 2)
> > When we do below:
> > alter subscription sub1 SET (conflict_log_table=clt2);
> >
> > the previous conflict log table is dropped. Is this behavior
> > intentional and discussed/concluded earlier? It’s possible that a user
> > may want to create a new conflict log table for future events while
> > still retaining the old one for analysis. If the subscription itself
> > is dropped, then dropping the CLT makes sense, but I’m not sure this
> > behavior is intended for ALTER SUBSCRIPTION. I do understand that
> > once we unlink CLT from subscription, later even DROP subscription
> > cannot drop it, but user can always drop it when not needed.
> >
> > If we plan to keep existing behavior, it should be clearly documented
> > in a CAUTION section, and the command should explicitly log the table
> > drop.
>
> Yeah we discussed this behavior and the conclusion was we would
> document this behavior and its user's responsibility to take necessary
> backup of the conflict log table data if they are setting a new log
> table or NONE for the subscription.
>
+1. If we don't do this then it will be difficult to track for
postgres or users the previous conflict history tables.
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-11 14:19 Dilip Kumar <dilipbalaut@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
0 siblings, 4 replies; 912+ messages in thread
From: Dilip Kumar @ 2025-12-11 14:19 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, Dec 11, 2025 at 5:57 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Thu, Dec 11, 2025 at 5:10 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Thu, Dec 11, 2025 at 5:04 PM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > > 2)
> > > When we do below:
> > > alter subscription sub1 SET (conflict_log_table=clt2);
> > >
> > > the previous conflict log table is dropped. Is this behavior
> > > intentional and discussed/concluded earlier? It’s possible that a user
> > > may want to create a new conflict log table for future events while
> > > still retaining the old one for analysis. If the subscription itself
> > > is dropped, then dropping the CLT makes sense, but I’m not sure this
> > > behavior is intended for ALTER SUBSCRIPTION. I do understand that
> > > once we unlink CLT from subscription, later even DROP subscription
> > > cannot drop it, but user can always drop it when not needed.
> > >
> > > If we plan to keep existing behavior, it should be clearly documented
> > > in a CAUTION section, and the command should explicitly log the table
> > > drop.
> >
> > Yeah we discussed this behavior and the conclusion was we would
> > document this behavior and its user's responsibility to take necessary
> > backup of the conflict log table data if they are setting a new log
> > table or NONE for the subscription.
> >
>
> +1. If we don't do this then it will be difficult to track for
> postgres or users the previous conflict history tables.
Right, it makes sense.
Attached patch fixed most of the open comments
1) \dRs+ now show the schema qualified name
2) Now key_tuple and replica_identify tuple both are add in conflict
log tuple wherever applicable
3) Refactored the code so that we can define the conflict log table
schema only once in the header file and both create_conflict_log_table
and ValidateConflictLogTable use it.
I was considering the interdependence between the subscription and the
conflict log table (CLT). IMHO, it would be logical to establish the
subscription as dependent on the CLT. This way, if someone attempts to
drop the CLT, the system would recognize the dependency of the
subscription and prevent the drop unless the subscription is removed
first or the CASCADE option is used.
However, while investigating this, I encountered an error [1] stating
that global objects are not supported in this context. This indicates
that global objects cannot be made dependent on local objects.
Although making an object dependent on global/shared objects is
possible for certain types of shared objects [2], this is not our main
objective.
We do not need to make the CLT dependent on the subscription because
the table can be dropped when the subscription is dropped anyway and
we are already doing it as part of drop subscription as well as alter
subscription when CLT is set to NONE or a different table. Therefore,
extending the functionality of shared dependency is unnecessary for
this purpose.
Thoughts?
[1]
doDeletion()
{
....
/*
* These global object types are not supported here.
*/
case AuthIdRelationId:
case DatabaseRelationId:
case TableSpaceRelationId:
case SubscriptionRelationId:
case ParameterAclRelationId:
elog(ERROR, "global objects cannot be deleted by doDeletion");
break;
}
[2]
typedef enum SharedDependencyType
{
SHARED_DEPENDENCY_OWNER = 'o',
SHARED_DEPENDENCY_ACL = 'a',
SHARED_DEPENDENCY_INITACL = 'i',
SHARED_DEPENDENCY_POLICY = 'r',
SHARED_DEPENDENCY_TABLESPACE = 't',
SHARED_DEPENDENCY_INVALID = 0,
} SharedDependencyType;
Pending Items are:
1. Handling dump/upgrade
2. Test case for conflit insertion
3. Documentation patch
--
Regards,
Dilip Kumar
Google
Attachments:
[application/octet-stream] v11-0001-Add-configurable-conflict-log-table-for-Logical-.patch (123.6K, ../../CAFiTN-vrKc6OWzrg6yvpwYcj79k=zkrDp3uwiZzjwrWLJAq6tw@mail.gmail.com/2-v11-0001-Add-configurable-conflict-log-table-for-Logical-.patch)
download | inline diff:
From dba9e431bd7a6025ba0cd2ad959f2b6a65b02e6e Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 12 Nov 2025 10:43:19 +0530
Subject: [PATCH v11] Add configurable conflict log table for Logical
Replication
This patch adds a feature to provide a structured, queryable record of all
logical replication conflicts. The current approach of logging conflicts as
plain text in the server logs makes it difficult to query, analyze, and
use for external monitoring and automation.
This patch addresses these limitations by introducing a configurable
conflict_log_table option in the CREATE SUBSCRIPTION command. Key design
decisions include:
User-Managed Table: The conflict log is stored in a user-managed table
rather than a system catalog.
Structured Data: Conflict details, including the original and remote tuples,
are stored in JSON columns, providing a flexible format to accommodate different
table schemas.
Comprehensive Information: The log table captures essential attributes such as
local and remote transaction IDs, LSNs, commit timestamps, and conflict type,
providing a complete record for post-mortem analysis.
This feature will make logical replication conflicts easier to monitor and manage,
significantly improving the overall resilience and operability of replication setups.
The conflict log tables will not be included in a publication, even if the publication
is configured to include ALL TABLES or ALL TABLES IN SCHEMA.
Note: A single remote tuple may conflict with multiple local tuples when conflict type
is CT_MULTIPLE_UNIQUE_CONFLICTS, so for handling this case we create a single row in
conflict log table with respect to each remote conflict tuple even if it conflicts with
multiple local tuples and we store the multiple conflict tuples as a single JSON array
element in format as
[ { "xid": "1001", "commit_ts": "...", "origin": "...", "tuple": {...} }, ... ]
We can extract the elements of the local tuples from the conflict log table row
as given in below example.
SELECT remote_xid, relname, remote_origin, local_conflicts[1] ->> 'xid' AS local_xid,
local_conflicts[1] ->> 'tuple' AS local_tuple
FROM myschema.conflict_log_history2;
remote_xid | relname | remote_origin | local_xid | local_tuple
------------+----------+---------------+-----------+---------------------
760 | test | pg_16406 | 771 | {"a":1,"b":10}
765 | conf_tab | pg_16406 | 775 | {"a":2,"b":2,"c":2}
---
src/backend/catalog/pg_publication.c | 26 +-
src/backend/commands/subscriptioncmds.c | 281 +++++++++-
src/backend/replication/logical/conflict.c | 579 ++++++++++++++++++++-
src/backend/replication/logical/launcher.c | 1 +
src/backend/replication/logical/worker.c | 37 +-
src/backend/utils/cache/lsyscache.c | 38 ++
src/bin/psql/describe.c | 24 +-
src/bin/psql/tab-complete.in.c | 8 +-
src/include/catalog/pg_subscription.h | 5 +
src/include/commands/subscriptioncmds.h | 2 +
src/include/replication/conflict.h | 32 ++
src/include/replication/worker_internal.h | 7 +
src/include/utils/lsyscache.h | 1 +
src/test/regress/expected/subscription.out | 360 +++++++++----
src/test/regress/sql/subscription.sql | 108 ++++
15 files changed, 1381 insertions(+), 128 deletions(-)
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index 0994220c53d..a0efac9fa50 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -31,6 +31,7 @@
#include "catalog/pg_publication_rel.h"
#include "catalog/pg_type.h"
#include "commands/publicationcmds.h"
+#include "commands/subscriptioncmds.h"
#include "funcapi.h"
#include "utils/array.h"
#include "utils/builtins.h"
@@ -85,6 +86,15 @@ check_publication_add_relation(Relation targetrel)
errmsg("cannot add relation \"%s\" to publication",
RelationGetRelationName(targetrel)),
errdetail("This operation is not supported for unlogged tables.")));
+
+ /* Can't be conflict log table */
+ if (IsConflictLogTable(RelationGetRelid(targetrel)))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot add relation \"%s.%s\" to publication",
+ get_namespace_name(RelationGetNamespace(targetrel)),
+ RelationGetRelationName(targetrel)),
+ errdetail("This operation is not supported for conflict log tables.")));
}
/*
@@ -145,6 +155,13 @@ is_publishable_class(Oid relid, Form_pg_class reltuple)
/*
* Another variant of is_publishable_class(), taking a Relation.
+ *
+ * Note: Conflict log tables are not publishable. However, we intentionally
+ * skip this check here because this function is called for every change and
+ * performing this check during every change publication is costly. To ensure
+ * unpublishable entries are ignored without incurring performance overhead,
+ * tuples inserted into the conflict log table uses the HEAP_INSERT_NO_LOGICAL
+ * flag. This allows the decoding layer to bypass these entries automatically.
*/
bool
is_publishable_relation(Relation rel)
@@ -169,7 +186,10 @@ pg_relation_is_publishable(PG_FUNCTION_ARGS)
tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
if (!HeapTupleIsValid(tuple))
PG_RETURN_NULL();
- result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple));
+
+ /* Subscription conflict log tables are not published */
+ result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple)) &&
+ !IsConflictLogTable(relid);
ReleaseSysCache(tuple);
PG_RETURN_BOOL(result);
}
@@ -890,7 +910,9 @@ GetAllPublicationRelations(char relkind, bool pubviaroot)
Form_pg_class relForm = (Form_pg_class) GETSTRUCT(tuple);
Oid relid = relForm->oid;
+ /* Subscription conflict log tables are not published */
if (is_publishable_class(relid, relForm) &&
+ !IsConflictLogTable(relid) &&
!(relForm->relispartition && pubviaroot))
result = lappend_oid(result, relid);
}
@@ -1018,7 +1040,7 @@ GetSchemaPublicationRelations(Oid schemaid, PublicationPartOpt pub_partopt)
Oid relid = relForm->oid;
char relkind;
- if (!is_publishable_class(relid, relForm))
+ if (!is_publishable_class(relid, relForm) || IsConflictLogTable(relid))
continue;
relkind = get_rel_relkind(relid);
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index 24b70234b35..3e126c541b8 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -15,16 +15,19 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/htup_details.h"
#include "access/table.h"
#include "access/twophase.h"
#include "access/xact.h"
#include "catalog/catalog.h"
#include "catalog/dependency.h"
+#include "catalog/heap.h"
#include "catalog/indexing.h"
#include "catalog/namespace.h"
#include "catalog/objectaccess.h"
#include "catalog/objectaddress.h"
+#include "catalog/pg_am_d.h"
#include "catalog/pg_authid_d.h"
#include "catalog/pg_database_d.h"
#include "catalog/pg_subscription.h"
@@ -34,9 +37,12 @@
#include "commands/event_trigger.h"
#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "executor/spi.h"
+#include "funcapi.h"
#include "miscadmin.h"
#include "nodes/makefuncs.h"
#include "pgstat.h"
+#include "replication/conflict.h"
#include "replication/logicallauncher.h"
#include "replication/logicalworker.h"
#include "replication/origin.h"
@@ -47,10 +53,12 @@
#include "storage/lmgr.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/guc.h"
#include "utils/lsyscache.h"
#include "utils/memutils.h"
#include "utils/pg_lsn.h"
+#include "utils/regproc.h"
#include "utils/syscache.h"
/*
@@ -75,6 +83,7 @@
#define SUBOPT_MAX_RETENTION_DURATION 0x00008000
#define SUBOPT_LSN 0x00010000
#define SUBOPT_ORIGIN 0x00020000
+#define SUBOPT_CONFLICT_LOG_TABLE 0x00040000
/* check if the 'val' has 'bits' set */
#define IsSet(val, bits) (((val) & (bits)) == (bits))
@@ -103,6 +112,7 @@ typedef struct SubOpts
bool retaindeadtuples;
int32 maxretention;
char *origin;
+ char *conflictlogtable;
XLogRecPtr lsn;
} SubOpts;
@@ -135,7 +145,9 @@ static List *merge_publications(List *oldpublist, List *newpublist, bool addpub,
static void ReportSlotConnectionError(List *rstates, Oid subid, char *slotname, char *err);
static void CheckAlterSubOption(Subscription *sub, const char *option,
bool slot_needs_update, bool isTopLevel);
-
+static void create_conflict_log_table(Oid namespaceId, char *conflictrel,
+ Oid subid);
+static void drop_conflict_log_table(Oid namespaceId, char *conflictrel);
/*
* Common option parsing function for CREATE and ALTER SUBSCRIPTION commands.
@@ -191,6 +203,8 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->maxretention = 0;
if (IsSet(supported_opts, SUBOPT_ORIGIN))
opts->origin = pstrdup(LOGICALREP_ORIGIN_ANY);
+ if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_TABLE))
+ opts->conflictlogtable = NULL;
/* Parse options */
foreach(lc, stmt_options)
@@ -402,6 +416,19 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->specified_opts |= SUBOPT_LSN;
opts->lsn = lsn;
}
+ else if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_TABLE) &&
+ strcmp(defel->defname, "conflict_log_table") == 0)
+ {
+ if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_LOG_TABLE))
+ errorConflictingDefElem(defel, pstate);
+
+ opts->specified_opts |= SUBOPT_CONFLICT_LOG_TABLE;
+ opts->conflictlogtable = defGetString(defel);
+
+ /* Setting conflict_log_table = NONE is treated as no table. */
+ if (strcmp(opts->conflictlogtable, "none") == 0)
+ opts->conflictlogtable = NULL;
+ }
else
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
@@ -599,6 +626,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bits32 supported_opts;
SubOpts opts = {0};
AclResult aclresult;
+ Oid conflictlogtable_nspid = InvalidOid;
+ char *conflictlogtable = NULL;
/*
* Parse and check options.
@@ -612,7 +641,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
SUBOPT_DISABLE_ON_ERR | SUBOPT_PASSWORD_REQUIRED |
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
- SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN);
+ SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_TABLE);
parse_subscription_options(pstate, stmt->options, supported_opts, &opts);
/*
@@ -747,6 +777,25 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
values[Anum_pg_subscription_suborigin - 1] =
CStringGetTextDatum(opts.origin);
+ /*
+ * If a conflict log table name is specified, parse the schema and table
+ * name from the string. Store the namespace OID and the table name in
+ * the pg_subscription catalog tuple.
+ */
+ if (opts.conflictlogtable)
+ {
+ List *names = stringToQualifiedNameList(opts.conflictlogtable, NULL);
+
+ conflictlogtable_nspid =
+ QualifiedNameGetCreationNamespace(names, &conflictlogtable);
+ values[Anum_pg_subscription_subconflictlognspid - 1] =
+ ObjectIdGetDatum(conflictlogtable_nspid);
+ values[Anum_pg_subscription_subconflictlogtable - 1] =
+ CStringGetTextDatum(conflictlogtable);
+ }
+ else
+ nulls[Anum_pg_subscription_subconflictlogtable - 1] = true;
+
tup = heap_form_tuple(RelationGetDescr(rel), values, nulls);
/* Insert tuple into catalog. */
@@ -768,6 +817,11 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
ReplicationOriginNameForLogicalRep(subid, InvalidOid, originname, sizeof(originname));
replorigin_create(originname);
+ /* If a conflict log table name is given then create the table. */
+ if (opts.conflictlogtable)
+ create_conflict_log_table(conflictlogtable_nspid, conflictlogtable,
+ subid);
+
/*
* Connect to remote side to execute requested commands and fetch table
* and sequence info.
@@ -1410,7 +1464,8 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
SUBOPT_MAX_RETENTION_DURATION |
- SUBOPT_ORIGIN);
+ SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_TABLE);
parse_subscription_options(pstate, stmt->options,
supported_opts, &opts);
@@ -1665,6 +1720,73 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
origin = opts.origin;
}
+ if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_LOG_TABLE))
+ {
+ Oid nspid = InvalidOid;
+ char *relname = NULL;
+ Oid old_nspid = InvalidOid;
+ char *old_relname = NULL;
+ List *names = NIL;
+
+ /* Fetch the eixsting conflict table table information. */
+ old_relname =
+ get_subscription_conflict_log_table(subid, &old_nspid);
+ if (opts.conflictlogtable != NULL)
+ {
+ names = stringToQualifiedNameList(opts.conflictlogtable,
+ NULL);
+ nspid = QualifiedNameGetCreationNamespace(names, &relname);
+ }
+
+ /*
+ * If the subscription already uses this conflict log table
+ * and it exists, just issue a notice.
+ */
+ if (old_relname != NULL && relname != NULL &&
+ strcmp(old_relname, relname) == 0 &&
+ old_nspid == nspid &&
+ OidIsValid(get_relname_relid(relname, nspid)))
+ {
+ char *nspname = get_namespace_name(nspid);
+
+ ereport(NOTICE,
+ (errmsg("\"%s.%s\" is already in use as the conflict log table for this subscription",
+ nspname, relname)));
+ pfree(nspname);
+ }
+ else
+ {
+ /* Need to drop the old table if one was set. */
+ if (old_relname != NULL)
+ drop_conflict_log_table(old_nspid, old_relname);
+
+ /*
+ * Need to create a new table if a new name was
+ * provided.
+ */
+ if (relname != NULL)
+ create_conflict_log_table(nspid, relname, subid);
+
+ values[Anum_pg_subscription_subconflictlognspid - 1] =
+ ObjectIdGetDatum(nspid);
+
+ if (relname != NULL)
+ values[Anum_pg_subscription_subconflictlogtable - 1] =
+ CStringGetTextDatum(relname);
+ else
+ nulls[Anum_pg_subscription_subconflictlogtable - 1] =
+ true;
+
+ replaces[Anum_pg_subscription_subconflictlognspid - 1] =
+ true;
+ replaces[Anum_pg_subscription_subconflictlogtable - 1] =
+ true;
+ }
+
+ if (old_relname != NULL)
+ pfree(old_relname);
+ }
+
update_tuple = true;
break;
}
@@ -2027,6 +2149,8 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
Form_pg_subscription form;
List *rstates;
bool must_use_password;
+ Oid conflictlogtable_nsp = InvalidOid;
+ char *conflictlogtable = NULL;
/*
* The launcher may concurrently start a new worker for this subscription.
@@ -2110,6 +2234,20 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
ObjectAddressSet(myself, SubscriptionRelationId, subid);
EventTriggerSQLDropAddObject(&myself, true, true);
+ /* Fetch the conflict log table information. */
+ conflictlogtable =
+ get_subscription_conflict_log_table(subid, &conflictlogtable_nsp);
+
+ /*
+ * If the subscription had a conflict log table, drop it now. This happens
+ * before deleting the subscription tuple.
+ */
+ if (conflictlogtable)
+ {
+ drop_conflict_log_table(conflictlogtable_nsp, conflictlogtable);
+ pfree(conflictlogtable);
+ }
+
/* Remove the tuple from catalog. */
CatalogTupleDelete(rel, &tup->t_self);
@@ -3188,3 +3326,140 @@ defGetStreamingMode(DefElem *def)
def->defname)));
return LOGICALREP_STREAM_OFF; /* keep compiler quiet */
}
+
+/*
+ * Builds the TupleDesc for the conflict log table.
+ */
+static TupleDesc
+create_conflict_log_table_tupdesc(void)
+{
+ TupleDesc tupdesc;
+ int i;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_CONFLICT_ATTR_NUM);
+
+ for (i = 0; i < MAX_CONFLICT_ATTR_NUM; i++)
+ {
+ Oid type_oid = ConflictLogSchema[i].atttypid;
+
+ /* Special handling for the JSON array type for proper TupleDescInitEntry call */
+ if (type_oid == JSONARRAYOID)
+ type_oid = get_array_type(JSONOID);
+
+ TupleDescInitEntry(tupdesc, i + 1,
+ ConflictLogSchema[i].attname,
+ type_oid,
+ -1, 0);
+ }
+
+ return BlessTupleDesc(tupdesc);
+}
+
+/*
+ * Create conflict log table.
+ *
+ * The subscription owner becomes the owner of this table and has all
+ * privileges on it.
+ */
+static void
+create_conflict_log_table(Oid namespaceId, char *conflictrel, Oid subid)
+{
+ TupleDesc tupdesc;
+
+ /* Report an error if the specified conflict log table already exists. */
+ if (OidIsValid(get_relname_relid(conflictrel, namespaceId)))
+ ereport(ERROR,
+ (errcode(ERRCODE_DUPLICATE_TABLE),
+ errmsg("cannot create conflict log table \"%s.%s\" because a table with that name already exists",
+ get_namespace_name(namespaceId), conflictrel),
+ errhint("Use a different name for the conflict log table or drop the existing table.")));
+
+ /* Build the tuple descriptor for the new table. */
+ tupdesc = create_conflict_log_table_tupdesc();
+
+ /* Create conflict log table. */
+ heap_create_with_catalog(conflictrel,
+ namespaceId,
+ 0,
+ InvalidOid,
+ InvalidOid,
+ InvalidOid,
+ GetUserId(),
+ HEAP_TABLE_AM_OID,
+ tupdesc,
+ NIL,
+ RELKIND_RELATION,
+ RELPERSISTENCE_PERMANENT,
+ false,
+ false,
+ ONCOMMIT_NOOP,
+ (Datum) 0,
+ false,
+ false,
+ false,
+ InvalidOid,
+ NULL);
+
+ /* Release tuple descriptor memory. */
+ FreeTupleDesc(tupdesc);
+}
+
+/*
+ * Drop the conflict log table.
+ */
+static void
+drop_conflict_log_table(Oid namespaceId, char *conflictrel)
+{
+ Oid relid;
+ ObjectAddress object;
+
+ relid = get_relname_relid(conflictrel, namespaceId);
+
+ /* Return quietly if the table does not exist (e.g., user dropped it) */
+ if (!OidIsValid(relid))
+ return;
+
+ /* Create the object address for the table. */
+ ObjectAddressSet(object, RelationRelationId, relid);
+
+ /*
+ * Perform the deletion. Using DROP_CASCADE ensures the deletion of
+ * dependent objects.
+ */
+ performDeletion(&object, DROP_CASCADE, 0);
+}
+
+/*
+ * Check if the specified relation is used as a conflict log table by any
+ * subscription.
+ */
+bool
+IsConflictLogTable(Oid relid)
+{
+ Relation rel;
+ TableScanDesc scan;
+ HeapTuple tup;
+ bool is_clt = false;
+
+ rel = table_open(SubscriptionRelationId, AccessShareLock);
+ scan = table_beginscan_catalog(rel, 0, NULL);
+
+ while (HeapTupleIsValid(tup = heap_getnext(scan, ForwardScanDirection)))
+ {
+ Form_pg_subscription subform = (Form_pg_subscription) GETSTRUCT(tup);
+ Oid nspid;
+ char *relname;
+
+ relname = get_subscription_conflict_log_table(subform->oid, &nspid);
+ if (relname && relid == get_relname_relid(relname, nspid))
+ {
+ is_clt = true;
+ break;
+ }
+ }
+
+ table_endscan(scan);
+ table_close(rel, AccessShareLock);
+
+ return is_clt;
+}
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 16695592265..f5de424bf7e 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -15,13 +15,31 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
+#include "access/htup.h"
+#include "access/skey.h"
#include "access/tableam.h"
+#include "access/table.h"
+#include "catalog/pg_attribute.h"
+#include "catalog/indexing.h"
+#include "catalog/namespace.h"
+#include "catalog/pg_namespace.h"
+#include "catalog/pg_type.h"
#include "executor/executor.h"
+#include "executor/spi.h"
+#include "funcapi.h"
#include "pgstat.h"
#include "replication/conflict.h"
#include "replication/worker_internal.h"
#include "storage/lmgr.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/lsyscache.h"
+#include "utils/pg_lsn.h"
+#include "utils/array.h"
+#include "utils/jsonb.h"
+
+#define MAX_LOCAL_CONFLICT_INFO_ATTRS 5
static const char *const ConflictTypeNames[] = {
[CT_INSERT_EXISTS] = "insert_exists",
@@ -50,8 +68,27 @@ static char *build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *localslot,
TupleTableSlot *remoteslot,
Oid indexoid);
+static void build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull);
static char *build_index_value_desc(EState *estate, Relation localrel,
TupleTableSlot *slot, Oid indexoid);
+static Datum tuple_table_slot_to_json_datum(TupleTableSlot *slot);
+static Datum tuple_table_slot_to_indextup_json(EState *estate,
+ Relation localrel,
+ Oid replica_index,
+ TupleTableSlot *slot);
+static TupleDesc build_conflict_tupledesc(void);
+static Datum build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples);
+static void prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot);
/*
* Get the xmin and commit timestamp data (origin and timestamp) associated
@@ -106,6 +143,7 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
TupleTableSlot *remoteslot, List *conflicttuples)
{
Relation localrel = relinfo->ri_RelationDesc;
+ Relation conflictlogrel = GetConflictLogTableRel();
StringInfoData err_detail;
initStringInfo(&err_detail);
@@ -120,6 +158,37 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
conflicttuple->ts,
&err_detail);
+ /* Insert conflict details to conflict log table. */
+ if (conflictlogrel)
+ {
+ if (ValidateConflictLogTable(conflictlogrel))
+ {
+ /*
+ * Prepare the conflict log tuple. If the error level is below
+ * ERROR, insert it immediately. Otherwise, defer the insertion to
+ * a new transaction after the current one aborts, ensuring the
+ * insertion of the log tuple is not rolled back.
+ */
+ prepare_conflict_log_tuple(estate,
+ relinfo->ri_RelationDesc,
+ conflictlogrel,
+ type,
+ searchslot,
+ conflicttuples,
+ remoteslot);
+ if (elevel < ERROR)
+ InsertConflictLogTuple(conflictlogrel);
+ }
+ else
+ ereport(WARNING,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("Conflict log table \"%s.%s\" structure changed, skipping insertion",
+ get_namespace_name(RelationGetNamespace(conflictlogrel)),
+ RelationGetRelationName(conflictlogrel)));
+
+ table_close(conflictlogrel, RowExclusiveLock);
+ }
+
pgstat_report_subscription_conflict(MySubscription->oid, type);
ereport(elevel,
@@ -162,6 +231,141 @@ InitConflictIndexes(ResultRelInfo *relInfo)
relInfo->ri_onConflictArbiterIndexes = uniqueIndexes;
}
+/*
+ * GetConflictLogTableRel
+ *
+ * Get the information of the specific conflict log table defined in
+ * pg_subscription and opens the relation for insertion. The caller is
+ * responsible for closing the returned relation handle.
+ */
+Relation
+GetConflictLogTableRel(void)
+{
+ Oid nspid;
+ Oid conflictlogrelid;
+ Relation conflictlogrel = NULL;
+ char *conflictlogtable;
+
+ /* If conflict log table is not set for the subscription just return. */
+ conflictlogtable = get_subscription_conflict_log_table(
+ MyLogicalRepWorker->subid, &nspid);
+ if (conflictlogtable == NULL)
+ return NULL;
+
+ conflictlogrelid = get_relname_relid(conflictlogtable, nspid);
+ if (OidIsValid(conflictlogrelid))
+ conflictlogrel = table_open(conflictlogrelid, RowExclusiveLock);
+
+ /* Conflict log table is dropped or not accessible. */
+ if (conflictlogrel == NULL)
+ ereport(WARNING,
+ (errcode(ERRCODE_UNDEFINED_TABLE),
+ errmsg("conflict log table \"%s.%s\" does not exist",
+ get_namespace_name(nspid), conflictlogtable)));
+
+ pfree(conflictlogtable);
+
+ return conflictlogrel;
+}
+
+/*
+ * InsertConflictLogTuple
+ *
+ * Insert conflict log tuple into the conflict log table. It uses
+ * HEAP_INSERT_NO_LOGICAL to explicitly block logical decoding
+ * of the tuple inserted into the conflict log table.
+ */
+void
+InsertConflictLogTuple(Relation conflictlogrel)
+{
+ int options = HEAP_INSERT_NO_LOGICAL;
+
+ /* A valid tuple must be prepared and stored in MyLogicalRepWorker. */
+ Assert(MyLogicalRepWorker->conflict_log_tuple != NULL);
+
+ heap_insert(conflictlogrel, MyLogicalRepWorker->conflict_log_tuple,
+ GetCurrentCommandId(true), options, NULL);
+
+ /* Free conflict log tuple. */
+ heap_freetuple(MyLogicalRepWorker->conflict_log_tuple);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+}
+
+/*
+ * ValidateConflictLogTable - Validate conflict log table
+ *
+ * Validate whether the conflict log table is still suitable for considering as
+ * conflict log table.
+ */
+bool
+ValidateConflictLogTable(Relation rel)
+{
+ Relation pg_attribute;
+ HeapTuple atup;
+ ScanKeyData scankey;
+ SysScanDesc scan;
+ Form_pg_attribute attForm;
+ int attcnt = 0;
+ bool tbl_ok = true;
+
+ /*
+ * Check whether the table definition including its column names, data
+ * types, and column ordering meets the requirements for conflict log
+ * table.
+ */
+ pg_attribute = table_open(AttributeRelationId, AccessShareLock);
+ ScanKeyInit(&scankey,
+ Anum_pg_attribute_attrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationGetRelid(rel)));
+
+ scan = systable_beginscan(pg_attribute, AttributeRelidNumIndexId, true,
+ SnapshotSelf, 1, &scankey);
+
+ /* We only need to check up to MAX_CONFLICT_ATTR_NUM attributes */
+ while (HeapTupleIsValid(atup = systable_getnext(scan)))
+ {
+ const ConflictLogColumnDef *expected;
+ int schema_idx;
+
+ attForm = (Form_pg_attribute) GETSTRUCT(atup);
+
+ /* Skip system columns and dropped columns */
+ if (attForm->attnum < 1 || attForm->attisdropped)
+ continue;
+
+ attcnt++;
+
+ /* attnum 1 corresponds to index 0 in ConflictLogSchema */
+ schema_idx = attForm->attnum - 1;
+
+ /* Check against the central schema definition */
+ if (schema_idx >= MAX_CONFLICT_ATTR_NUM)
+ {
+ /* Found an extra column beyond the required set */
+ tbl_ok = false;
+ break;
+ }
+
+ expected = &ConflictLogSchema[schema_idx];
+
+ if (attForm->atttypid != expected->atttypid ||
+ strcmp(NameStr(attForm->attname), expected->attname) != 0)
+ {
+ tbl_ok = false;
+ break;
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_attribute, AccessShareLock);
+
+ if (attcnt != MAX_CONFLICT_ATTR_NUM || !tbl_ok)
+ return false;
+
+ return true;
+}
+
/*
* Add SQLSTATE error code to the current conflict report.
*/
@@ -472,6 +676,40 @@ build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
return tuple_value.data;
}
+/*
+ * Helper function to extract the "raw" index key Datums and their null flags
+ * from a TupleTableSlot, given an already open index descriptor.
+ * This is the reusable core logic.
+ */
+static void
+build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull)
+{
+ TupleTableSlot *tableslot = slot;
+
+ /*
+ * If the slot is a virtual slot, copy it into a heap tuple slot as
+ * FormIndexDatum only works with heap tuple slots.
+ */
+ if (TTS_IS_VIRTUAL(slot))
+ {
+ /* Slot is created within the EState's tuple table */
+ tableslot = table_slot_create(localrel, &estate->es_tupleTable);
+ tableslot = ExecCopySlot(tableslot, slot);
+ }
+
+ /*
+ * Initialize ecxt_scantuple for potential use in FormIndexDatum
+ */
+ GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+
+ /* Form the index datums */
+ FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values,
+ isnull);
+}
+
/*
* Helper functions to construct a string describing the contents of an index
* entry. See BuildIndexValueDescription for details.
@@ -487,41 +725,336 @@ build_index_value_desc(EState *estate, Relation localrel, TupleTableSlot *slot,
Relation indexDesc;
Datum values[INDEX_MAX_KEYS];
bool isnull[INDEX_MAX_KEYS];
- TupleTableSlot *tableslot = slot;
- if (!tableslot)
+ if (!slot)
return NULL;
Assert(CheckRelationOidLockedByMe(indexoid, RowExclusiveLock, true));
indexDesc = index_open(indexoid, NoLock);
- /*
- * If the slot is a virtual slot, copy it into a heap tuple slot as
- * FormIndexDatum only works with heap tuple slots.
- */
- if (TTS_IS_VIRTUAL(slot))
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+
+ index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+
+ index_close(indexDesc, NoLock);
+
+ return index_value;
+}
+
+/*
+ * tuple_table_slot_to_json_datum
+ *
+ * Helper function to convert a TupleTableSlot to Jsonb.
+ */
+static Datum
+tuple_table_slot_to_json_datum(TupleTableSlot *slot)
+{
+ HeapTuple tuple;
+ Datum datum;
+ Datum json;
+
+ Assert(slot != NULL);
+
+ tuple = ExecCopySlotHeapTuple(slot);
+ datum = heap_copy_tuple_as_datum(tuple, slot->tts_tupleDescriptor);
+
+ json = DirectFunctionCall1(row_to_json, datum);
+ heap_freetuple(tuple);
+
+ return json;
+}
+
+/*
+ * tuple_table_slot_to_indextup_json
+ *
+ * Fetch replica identity key from the tuple table slot and convert into a
+ * jsonb datum.
+ */
+static Datum
+tuple_table_slot_to_indextup_json(EState *estate, Relation localrel,
+ Oid indexid, TupleTableSlot *slot)
+{
+ Relation indexDesc;
+ Datum values[INDEX_MAX_KEYS];
+ bool isnull[INDEX_MAX_KEYS];
+ HeapTuple tuple;
+ TupleDesc tupdesc;
+ Datum datum;
+
+ Assert(slot != NULL);
+
+ Assert(CheckRelationOidLockedByMe(indexid, RowExclusiveLock, true));
+
+ indexDesc = index_open(indexid, NoLock);
+
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+ tupdesc = RelationGetDescr(indexDesc);
+
+ /* Bless the tupdesc so it can be looked up by row_to_json. */
+ BlessTupleDesc(tupdesc);
+
+ /* Form the replica identity tuple. */
+ tuple = heap_form_tuple(tupdesc, values, isnull);
+ datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ index_close(indexDesc, NoLock);
+
+ /* Convert to a JSONB datum. */
+ return DirectFunctionCall1(row_to_json, datum);
+}
+
+/*
+ * Initialize the tuple descriptor for local conflict info.
+ */
+static TupleDesc
+build_conflict_tupledesc(void)
+{
+ TupleDesc tupdesc;
+ int attno = 1;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ TupleDescInitEntry(tupdesc, (AttrNumber) attno++, "xid",
+ XIDOID, -1, 0);
+ TupleDescInitEntry(tupdesc, (AttrNumber) attno++, "commit_ts",
+ TIMESTAMPTZOID, -1, 0);
+ TupleDescInitEntry(tupdesc, (AttrNumber) attno++, "origin",
+ TEXTOID, -1, 0);
+ TupleDescInitEntry(tupdesc, (AttrNumber) attno++, "key",
+ JSONOID, -1, 0);
+ TupleDescInitEntry(tupdesc, (AttrNumber) attno, "tuple",
+ JSONOID, -1, 0);
+
+ BlessTupleDesc(tupdesc);
+
+ Assert(attno == MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ return tupdesc;
+}
+
+/*
+ * Builds the local conflicts JSONB array column from the list of
+ * ConflictTupleInfo objects.
+ *
+ * Example output structure:
+ * [ { "xid": "1001", "commit_ts": "...", "origin": "...", "tuple": {...} }, ... ]
+ */
+static Datum
+build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples)
+{
+ ListCell *lc;
+ List *json_datums = NIL; /* List to hold the row_to_json results (type json) */
+ Datum *json_datum_array;
+ bool *json_null_array;
+ Datum json_array_datum;
+ int num_conflicts;
+ int i;
+ int16 typlen;
+ bool typbyval;
+ char typalign;
+ TupleDesc tupdesc;
+
+ /* Build local conflicts tuple descriptor. */
+ tupdesc = build_conflict_tupledesc();
+
+ /* Process local conflict tuple list and prepare an array of JSON. */
+ foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
{
- tableslot = table_slot_create(localrel, &estate->es_tupleTable);
- tableslot = ExecCopySlot(tableslot, slot);
+ Datum values[MAX_LOCAL_CONFLICT_INFO_ATTRS];
+ bool nulls[MAX_LOCAL_CONFLICT_INFO_ATTRS];
+ char *origin_name = NULL;
+ HeapTuple tuple;
+ Datum json_datum;
+ int attno;
+
+ memset(values, 0, sizeof(Datum) * MAX_LOCAL_CONFLICT_INFO_ATTRS);
+ memset(nulls, 0, sizeof(bool) * MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ attno = 0;
+ values[attno++] = TransactionIdGetDatum(conflicttuple->xmin);
+
+ if (conflicttuple->ts)
+ values[attno++] = TimestampTzGetDatum(conflicttuple->ts);
+ else
+ nulls[attno++] = true;
+
+ if (conflicttuple->origin != InvalidRepOriginId)
+ replorigin_by_oid(conflicttuple->origin, true, &origin_name);
+
+ /* Store empty string if origin name for the tuple is NULL. */
+ if (origin_name != NULL)
+ values[attno++] = CStringGetTextDatum(origin_name);
+ else
+ nulls[attno++] = true;
+
+ /*
+ * Add the conflicting key values in the case of a unique constraint
+ * violation.
+ */
+ if (conflict_type == CT_INSERT_EXISTS ||
+ conflict_type == CT_UPDATE_EXISTS ||
+ conflict_type == CT_MULTIPLE_UNIQUE_CONFLICTS)
+ {
+ Oid indexoid = conflicttuple->indexoid;
+
+ Assert(OidIsValid(indexoid) && conflicttuple->slot &&
+ CheckRelationOidLockedByMe(indexoid, RowExclusiveLock,
+ true));
+ values[attno++] =
+ tuple_table_slot_to_indextup_json(estate, rel,
+ indexoid,
+ conflicttuple->slot);
+ }
+ else
+ nulls[attno++] = true;
+
+ /* Convert conflicting tuple to JSON datum. */
+ if (conflicttuple->slot)
+ values[attno] = tuple_table_slot_to_json_datum(conflicttuple->slot);
+ else
+ nulls[attno] = true;
+
+ Assert(attno + 1 == MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ tuple = heap_form_tuple(tupdesc, values, nulls);
+
+ json_datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ /*
+ * Build the higher level JSON datum in format described in function
+ * header.
+ */
+ json_datum = DirectFunctionCall1(row_to_json, json_datum);
+
+ /* Done with the temporary tuple. */
+ heap_freetuple(tuple);
+
+ /* Add to the array element. */
+ json_datums = lappend(json_datums, (void *) json_datum);
}
- /*
- * Initialize ecxt_scantuple for potential use in FormIndexDatum when
- * index expressions are present.
- */
- GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+ num_conflicts = list_length(json_datums);
- /*
- * The values/nulls arrays passed to BuildIndexValueDescription should be
- * the results of FormIndexDatum, which are the "raw" input to the index
- * AM.
- */
- FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values, isnull);
+ json_datum_array = (Datum *) palloc(num_conflicts * sizeof(Datum));
+ json_null_array = (bool *) palloc0(num_conflicts * sizeof(bool));
- index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+ i = 0;
+ foreach(lc, json_datums)
+ {
+ json_datum_array[i] = (Datum) lfirst(lc);
+ i++;
+ }
- index_close(indexDesc, NoLock);
+ /* Construct the json[] array Datum. */
+ get_typlenbyvalalign(JSONOID, &typlen, &typbyval, &typalign);
+ json_array_datum = PointerGetDatum(construct_array(json_datum_array,
+ num_conflicts,
+ JSONOID,
+ typlen,
+ typbyval,
+ typalign));
+ pfree(json_datum_array);
+ pfree(json_null_array);
+
+ return json_array_datum;
+}
- return index_value;
+/*
+ * prepare_conflict_log_tuple
+ *
+ * This routine prepares a tuple detailing a conflict encountered during
+ * logical replication. The prepared tuple will be stored in
+ * MyLogicalRepWorker->conflict_log_tuple which should be inserted into the
+ * conflict log table by calling InsertConflictLogTuple.
+ */
+static void
+prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot)
+{
+ Datum values[MAX_CONFLICT_ATTR_NUM];
+ bool nulls[MAX_CONFLICT_ATTR_NUM];
+ int attno;
+ char *remote_origin = NULL;
+ MemoryContext oldctx;
+
+ Assert(MyLogicalRepWorker->conflict_log_tuple == NULL);
+
+ /* Initialize values and nulls arrays. */
+ memset(values, 0, sizeof(Datum) * MAX_CONFLICT_ATTR_NUM);
+ memset(nulls, 0, sizeof(bool) * MAX_CONFLICT_ATTR_NUM);
+
+ /* Populate the values and nulls arrays. */
+ attno = 0;
+ values[attno++] = ObjectIdGetDatum(RelationGetRelid(rel));
+
+ values[attno++] =
+ CStringGetTextDatum(get_namespace_name(RelationGetNamespace(rel)));
+
+ values[attno++] = CStringGetTextDatum(RelationGetRelationName(rel));
+
+ values[attno++] = CStringGetTextDatum(ConflictTypeNames[conflict_type]);
+
+ if (TransactionIdIsValid(remote_xid))
+ values[attno++] = TransactionIdGetDatum(remote_xid);
+ else
+ nulls[attno++] = true;
+
+ values[attno++] = LSNGetDatum(remote_final_lsn);
+
+ if (remote_commit_ts > 0)
+ values[attno++] = TimestampTzGetDatum(remote_commit_ts);
+ else
+ nulls[attno++] = true;
+
+ if (replorigin_session_origin != InvalidRepOriginId)
+ replorigin_by_oid(replorigin_session_origin, true, &remote_origin);
+
+ if (remote_origin != NULL)
+ values[attno++] = CStringGetTextDatum(remote_origin);
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(searchslot))
+ {
+ Oid replica_index = GetRelationIdentityOrPK(rel);
+
+ /*
+ * If the table has a valid replica identity index, build the index
+ * json datum from key value. Otherwise, construct it from the complete
+ * tuple in REPLICA IDENTITY FULL cases.
+ */
+ if (OidIsValid(replica_index))
+ values[attno++] = tuple_table_slot_to_indextup_json(estate, rel,
+ replica_index,
+ searchslot);
+ else
+ values[attno++] = tuple_table_slot_to_json_datum(searchslot);
+ }
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(remoteslot))
+ values[attno++] = tuple_table_slot_to_json_datum(remoteslot);
+ else
+ nulls[attno++] = true;
+
+ values[attno] = build_local_conflicts_json_array(estate, rel,
+ conflict_type,
+ conflicttuples);
+
+ Assert(attno + 1 == MAX_CONFLICT_ATTR_NUM);
+
+ oldctx = MemoryContextSwitchTo(ApplyContext);
+ MyLogicalRepWorker->conflict_log_tuple =
+ heap_form_tuple(RelationGetDescr(conflictlogrel), values, nulls);
+ MemoryContextSwitchTo(oldctx);
}
diff --git a/src/backend/replication/logical/launcher.c b/src/backend/replication/logical/launcher.c
index fdf1ccad462..2364146ca36 100644
--- a/src/backend/replication/logical/launcher.c
+++ b/src/backend/replication/logical/launcher.c
@@ -477,6 +477,7 @@ retry:
worker->oldest_nonremovable_xid = retain_dead_tuples
? MyReplicationSlot->data.xmin
: InvalidTransactionId;
+ worker->conflict_log_tuple = NULL;
worker->last_lsn = InvalidXLogRecPtr;
TIMESTAMP_NOBEGIN(worker->last_send_time);
TIMESTAMP_NOBEGIN(worker->last_recv_time);
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 93970c6af29..8055cd57a14 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -482,7 +482,9 @@ static bool MySubscriptionValid = false;
static List *on_commit_wakeup_workers_subids = NIL;
bool in_remote_transaction = false;
-static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+TransactionId remote_xid = InvalidTransactionId;
+TimestampTz remote_commit_ts = 0;
/* fields valid only when processing streamed transaction */
static bool in_streamed_transaction = false;
@@ -1219,6 +1221,8 @@ apply_handle_begin(StringInfo s)
set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
remote_final_lsn = begin_data.final_lsn;
+ remote_commit_ts = begin_data.committime;
+ remote_xid = begin_data.xid;
maybe_start_skipping_changes(begin_data.final_lsn);
@@ -1745,6 +1749,10 @@ apply_handle_stream_start(StringInfo s)
/* extract XID of the top-level transaction */
stream_xid = logicalrep_read_stream_start(s, &first_segment);
+ remote_xid = stream_xid;
+ remote_final_lsn = InvalidXLogRecPtr;
+ remote_commit_ts = 0;
+
if (!TransactionIdIsValid(stream_xid))
ereport(ERROR,
(errcode(ERRCODE_PROTOCOL_VIOLATION),
@@ -5609,6 +5617,33 @@ start_apply(XLogRecPtr origin_startpos)
pgstat_report_subscription_error(MySubscription->oid,
MyLogicalRepWorker->type);
+ /*
+ * Insert any pending conflict log tuple under a new transaction.
+ */
+ if (MyLogicalRepWorker->conflict_log_tuple != NULL)
+ {
+ Relation conflictlogrel;
+
+ StartTransactionCommand();
+ PushActiveSnapshot(GetTransactionSnapshot());
+
+ /* Open conflict log table and insert the tuple. */
+ conflictlogrel = GetConflictLogTableRel();
+ if (ValidateConflictLogTable(conflictlogrel))
+ InsertConflictLogTuple(conflictlogrel);
+ else
+ ereport(WARNING,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("Conflict log table \"%s.%s\" structure changed, skipping insertion",
+ get_namespace_name(RelationGetNamespace(conflictlogrel)),
+ RelationGetRelationName(conflictlogrel)));
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+ table_close(conflictlogrel, RowExclusiveLock);
+
+ PopActiveSnapshot();
+ CommitTransactionCommand();
+ }
+
PG_RE_THROW();
}
}
diff --git a/src/backend/utils/cache/lsyscache.c b/src/backend/utils/cache/lsyscache.c
index fa7cd7e06a7..12db6676406 100644
--- a/src/backend/utils/cache/lsyscache.c
+++ b/src/backend/utils/cache/lsyscache.c
@@ -3881,3 +3881,41 @@ get_subscription_name(Oid subid, bool missing_ok)
return subname;
}
+
+/*
+ * get_subscription_conflict_log_table
+ *
+ * Get conflict log table name and namespace id from subscription.
+ */
+char *
+get_subscription_conflict_log_table(Oid subid, Oid *nspid)
+{
+ HeapTuple tup;
+ Datum datum;
+ bool isnull;
+ char *relname = NULL;
+ Form_pg_subscription subform;
+
+ *nspid = InvalidOid;
+
+ tup = SearchSysCache1(SUBSCRIPTIONOID, ObjectIdGetDatum(subid));
+
+ if (!HeapTupleIsValid(tup))
+ return NULL;
+
+ subform = (Form_pg_subscription) GETSTRUCT(tup);
+
+ /* Get conflict log table name. */
+ datum = SysCacheGetAttr(SUBSCRIPTIONOID,
+ tup,
+ Anum_pg_subscription_subconflictlogtable,
+ &isnull);
+ if (!isnull)
+ {
+ *nspid = subform->subconflictlognspid;
+ relname = pstrdup(TextDatumGetCString(datum));
+ }
+
+ ReleaseSysCache(tup);
+ return relname;
+}
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index 36f24502842..906167fe466 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -6806,7 +6806,7 @@ describeSubscriptions(const char *pattern, bool verbose)
printQueryOpt myopt = pset.popt;
static const bool translate_columns[] = {false, false, false, false,
false, false, false, false, false, false, false, false, false, false,
- false, false, false, false};
+ false, false, false, false, false};
if (pset.sversion < 100000)
{
@@ -6900,15 +6900,25 @@ describeSubscriptions(const char *pattern, bool verbose)
appendPQExpBuffer(&buf,
", subskiplsn AS \"%s\"\n",
gettext_noop("Skip LSN"));
+
+ /* Conflict log table is only supported in v19 and higher */
+ if (pset.sversion >= 190000)
+ appendPQExpBuffer(&buf,
+ ", (CASE\n"
+ " WHEN subconflictlogtable IS NULL THEN NULL\n"
+ " ELSE pg_catalog.quote_ident(n.nspname) || '.' ||"
+ " pg_catalog.quote_ident(subconflictlogtable::text)\n"
+ "END) AS \"%s\"\n",
+ gettext_noop("Conflict log table"));
}
/* Only display subscriptions in current database. */
- appendPQExpBufferStr(&buf,
- "FROM pg_catalog.pg_subscription\n"
- "WHERE subdbid = (SELECT oid\n"
- " FROM pg_catalog.pg_database\n"
- " WHERE datname = pg_catalog.current_database())");
-
+ appendPQExpBuffer(&buf,
+ "FROM pg_catalog.pg_subscription "
+ "LEFT JOIN pg_catalog.pg_namespace AS n ON subconflictlognspid = n.oid\n"
+ "WHERE subdbid = (SELECT oid\n"
+ " FROM pg_catalog.pg_database\n"
+ " WHERE datname = pg_catalog.current_database())");
if (!validateSQLNamePattern(&buf, pattern, true, false,
NULL, "subname", NULL,
NULL,
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 20d7a65c614..6ab4ab8857d 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -2344,8 +2344,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("(", "PUBLICATION");
/* ALTER SUBSCRIPTION <name> SET ( */
else if (Matches("ALTER", "SUBSCRIPTION", MatchAny, MatchAnyN, "SET", "("))
- COMPLETE_WITH("binary", "disable_on_error", "failover",
- "max_retention_duration", "origin",
+ COMPLETE_WITH("binary", "conflict_log_table", "disable_on_error",
+ "failover", "max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
"synchronous_commit", "two_phase");
@@ -3814,8 +3814,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("WITH (");
/* Complete "CREATE SUBSCRIPTION <name> ... WITH ( <opt>" */
else if (Matches("CREATE", "SUBSCRIPTION", MatchAnyN, "WITH", "("))
- COMPLETE_WITH("binary", "connect", "copy_data", "create_slot",
- "disable_on_error", "enabled", "failover",
+ COMPLETE_WITH("binary", "conflict_log_table", "connect", "copy_data",
+ "create_slot", "disable_on_error", "enabled", "failover",
"max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index 55cb9b1eefa..f4526c15ec3 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -80,6 +80,8 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
bool subretaindeadtuples; /* True if dead tuples useful for
* conflict detection are retained */
+ Oid subconflictlognspid; /* Namespace Oid in which the conflict
+ * log table is created. */
int32 submaxretention; /* The maximum duration (in milliseconds)
* for which information useful for
@@ -105,6 +107,9 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
/* Only publish data originating from the specified origin */
text suborigin BKI_DEFAULT(LOGICALREP_ORIGIN_ANY);
+
+ /* Conflict log table name if specified */
+ text subconflictlogtable;
#endif
} FormData_pg_subscription;
diff --git a/src/include/commands/subscriptioncmds.h b/src/include/commands/subscriptioncmds.h
index fb4e26a51a4..6c062b0991f 100644
--- a/src/include/commands/subscriptioncmds.h
+++ b/src/include/commands/subscriptioncmds.h
@@ -36,4 +36,6 @@ extern void CheckSubDeadTupleRetention(bool check_guc, bool sub_disabled,
bool retention_active,
bool max_retention_set);
+extern bool IsConflictLogTable(Oid relid);
+
#endif /* SUBSCRIPTIONCMDS_H */
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index c8fbf9e51b8..c7e67bd300e 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -9,9 +9,12 @@
#ifndef CONFLICT_H
#define CONFLICT_H
+#include "access/htup.h"
#include "access/xlogdefs.h"
+#include "catalog/pg_type.h"
#include "nodes/pg_list.h"
#include "utils/timestamp.h"
+#include "utils/relcache.h"
/* Avoid including execnodes.h here */
typedef struct EState EState;
@@ -79,6 +82,32 @@ typedef struct ConflictTupleInfo
* conflicting local row occurred */
} ConflictTupleInfo;
+/* Structure to hold metadata for one column of the conflict log table */
+typedef struct ConflictLogColumnDef
+{
+ const char *attname; /* Column name */
+ Oid atttypid; /* Data type OID */
+} ConflictLogColumnDef;
+
+/* The single source of truth for the conflict log table schema */
+static const ConflictLogColumnDef ConflictLogSchema[] =
+{
+ { .attname = "relid", .atttypid = OIDOID },
+ { .attname = "schemaname", .atttypid = TEXTOID },
+ { .attname = "relname", .atttypid = TEXTOID },
+ { .attname = "conflict_type", .atttypid = TEXTOID },
+ { .attname = "remote_xid", .atttypid = XIDOID },
+ { .attname = "remote_commit_lsn",.atttypid = LSNOID },
+ { .attname = "remote_commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "remote_origin", .atttypid = TEXTOID },
+ { .attname = "replica_identity", .atttypid = JSONOID },
+ { .attname = "remote_tuple", .atttypid = JSONOID },
+ { .attname = "local_conflicts", .atttypid = JSONARRAYOID }
+};
+
+/* Define the count using the array size */
+#define MAX_CONFLICT_ATTR_NUM (sizeof(ConflictLogSchema) / sizeof(ConflictLogSchema[0]))
+
extern bool GetTupleTransactionInfo(TupleTableSlot *localslot,
TransactionId *xmin,
RepOriginId *localorigin,
@@ -89,4 +118,7 @@ extern void ReportApplyConflict(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *remoteslot,
List *conflicttuples);
extern void InitConflictIndexes(ResultRelInfo *relInfo);
+extern Relation GetConflictLogTableRel(void);
+extern void InsertConflictLogTuple(Relation conflictlogrel);
+extern bool ValidateConflictLogTable(Relation rel);
#endif
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index f081619f151..711c04c7297 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -101,6 +101,9 @@ typedef struct LogicalRepWorker
*/
TransactionId oldest_nonremovable_xid;
+ /* A conflict log tuple that is prepared but not yet inserted. */
+ HeapTuple conflict_log_tuple;
+
/* Stats. */
XLogRecPtr last_lsn;
TimestampTz last_send_time;
@@ -256,6 +259,10 @@ extern PGDLLIMPORT bool InitializingApplyWorker;
extern PGDLLIMPORT List *table_states_not_ready;
+extern XLogRecPtr remote_final_lsn;
+extern TimestampTz remote_commit_ts;
+extern TransactionId remote_xid;
+
extern void logicalrep_worker_attach(int slot);
extern LogicalRepWorker *logicalrep_worker_find(LogicalRepWorkerType wtype,
Oid subid, Oid relid,
diff --git a/src/include/utils/lsyscache.h b/src/include/utils/lsyscache.h
index 50fb149e9ac..3bebf04bf51 100644
--- a/src/include/utils/lsyscache.h
+++ b/src/include/utils/lsyscache.h
@@ -210,6 +210,7 @@ extern Oid get_publication_oid(const char *pubname, bool missing_ok);
extern char *get_publication_name(Oid pubid, bool missing_ok);
extern Oid get_subscription_oid(const char *subname, bool missing_ok);
extern char *get_subscription_name(Oid subid, bool missing_ok);
+extern char *get_subscription_conflict_log_table(Oid subid, Oid *nspid);
#define type_is_array(typid) (get_element_type(typid) != InvalidOid)
/* type_is_array_domain accepts both plain arrays and domains over arrays */
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index 327d1e7731f..bab2d0ea954 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -116,18 +116,18 @@ CREATE SUBSCRIPTION regress_testsub4 CONNECTION 'dbname=regress_doesnotexist' PU
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub4 SET (origin = any);
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
DROP SUBSCRIPTION regress_testsub3;
@@ -145,10 +145,10 @@ ALTER SUBSCRIPTION regress_testsub CONNECTION 'foobar';
ERROR: invalid connection string syntax: missing "=" after "foobar" in connection info string
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET PUBLICATION testpub2, testpub3 WITH (refresh = false);
@@ -157,10 +157,10 @@ ALTER SUBSCRIPTION regress_testsub SET (slot_name = 'newname');
ALTER SUBSCRIPTION regress_testsub SET (password_required = false);
ALTER SUBSCRIPTION regress_testsub SET (run_as_owner = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (password_required = true);
@@ -176,10 +176,10 @@ ERROR: unrecognized subscription parameter: "create_slot"
-- ok
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/12345');
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345 |
(1 row)
-- ok - with lsn = NONE
@@ -188,10 +188,10 @@ ALTER SUBSCRIPTION regress_testsub SKIP (lsn = NONE);
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/0');
ERROR: invalid WAL location (LSN): 0/0
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 |
(1 row)
BEGIN;
@@ -223,10 +223,10 @@ ALTER SUBSCRIPTION regress_testsub_foo SET (synchronous_commit = foobar);
ERROR: invalid value for parameter "synchronous_commit": "foobar"
HINT: Available values: local, remote_write, remote_apply, on, off.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
----------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+---------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000 |
(1 row)
-- rename back to keep the rest simple
@@ -255,19 +255,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (binary = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -279,27 +279,27 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = parallel);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- fail - publication already exists
@@ -314,10 +314,10 @@ ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refr
ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refresh = false);
ERROR: publication "testpub1" is already in subscription "regress_testsub"
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- fail - publication used more than once
@@ -332,10 +332,10 @@ ERROR: publication "testpub3" is not in subscription "regress_testsub"
-- ok - delete publications
ALTER SUBSCRIPTION regress_testsub DROP PUBLICATION testpub1, testpub2 WITH (refresh = false);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -371,19 +371,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- we can alter streaming when two_phase enabled
ALTER SUBSCRIPTION regress_testsub SET (streaming = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -393,10 +393,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -409,18 +409,18 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (disable_on_error = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -433,10 +433,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -450,19 +450,19 @@ NOTICE: max_retention_duration is ineffective when retain_dead_tuples is disabl
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- ok
ALTER SUBSCRIPTION regress_testsub SET (max_retention_duration = 0);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -517,7 +517,191 @@ COMMIT;
-- ok, owning it is enough for this stuff
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG TABLE TESTS
+--
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+-- fail - conflict_log_table specified when table already exists
+CREATE TABLE public.regress_conflict_log_temp (id int);
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log_temp');
+ERROR: cannot create conflict log table "public.regress_conflict_log_temp" because a table with that name already exists
+HINT: Use a different name for the conflict log table or drop the existing table.
+DROP TABLE public.regress_conflict_log_temp;
+-- ok - conflict_log_table creation with CREATE SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- check metadata in pg_subscription
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname | subconflictlogtable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test1 | regress_conflict_log1 | t
+(1 row)
+
+-- check if the table exists and has the correct schema (11 columns)
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attnum > 0;
+ count
+-------
+ 11
+(1 row)
+
+-- check a specific column type (e.g., key_tuple should be JSON)
+SELECT format_type(atttypid, atttypmod) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attname = 'key_tuple';
+ format_type
+-------------
+ json
+(1 row)
+
+\dRs+
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+-----------------------
+ regress_conflict_test1 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | regress_conflict_log1
+(1 row)
+
+-- ok - adding conflict_log_table with ALTER SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log2');
+-- check metadata after ALTER
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogtable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test2 | regress_conflict_log2 | t
+(1 row)
+
+-- ok - change the conflict log table name for an existing subscription that already had one
+CREATE SCHEMA clt;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'clt.regress_conflict_log3');
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogtable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test2 | regress_conflict_log3 | f
+(1 row)
+
+\dRs+
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+-----------------------
+ regress_conflict_test1 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | regress_conflict_log1
+ regress_conflict_test2 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | regress_conflict_log3
+(2 rows)
+
+-- check the new table was created and the old table was dropped
+SELECT count(*) FROM pg_class WHERE relname = 'regress_conflict_log2';
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'clt.regress_conflict_log3'::regclass AND attnum > 0;
+ count
+-------
+ 11
+(1 row)
+
+-- ok (NOTICE) - set conflict_log_table to one already used by this subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'clt.regress_conflict_log3');
+NOTICE: "clt.regress_conflict_log3" is already in use as the conflict log table for this subscription
+-- fail - try to publish the conflict_log_table
+CREATE PUBLICATION pub FOR TABLE clt.regress_conflict_log3;
+ERROR: cannot add relation "clt.regress_conflict_log3" to publication
+DETAIL: This operation is not supported for conflict log tables.
+-- suppress warning that depends on wal_level
+SET client_min_messages = 'ERROR';
+-- ok - conflict_log_table should not be published with ALL TABLE
+CREATE PUBLICATION pub FOR TABLES IN SCHEMA clt;
+SELECT * FROM pg_publication_tables WHERE pubname = 'pub';
+ pubname | schemaname | tablename | attnames | rowfilter
+---------+------------+-----------+----------+-----------
+(0 rows)
+
+\dt+ clt.regress_conflict_log3
+ List of tables
+ Schema | Name | Type | Owner | Persistence | Size | Description
+--------+-----------------------+-------+---------------------------+-------------+---------+-------------
+ clt | regress_conflict_log3 | table | regress_subscription_user | permanent | 0 bytes |
+(1 row)
+
+DROP PUBLICATION pub;
+-- fail - set conflict_log_table to one already used by a different subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log1');
+ERROR: cannot create conflict log table "public.regress_conflict_log1" because a table with that name already exists
+HINT: Use a different name for the conflict log table or drop the existing table.
+-- ok - dropping subscription also drops the log table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the table was dropped
+SELECT to_regclass('public.regress_conflict_log1');
+ to_regclass
+-------------
+
+(1 row)
+
+-- ok - dropping subscription when the log table was manually dropped first
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+DROP TABLE public.regress_conflict_log1;
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the subscription was dropped successfully
+SELECT subname FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname
+---------
+(0 rows)
+
+-- ok - create subscription with conflict_log_table = NONE
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = NONE);
+SELECT subname, subconflictlogtable FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogtable
+------------------------+-----------------------
+ regress_conflict_test2 | regress_conflict_log3
+(1 row)
+
+--ok - alter subscription with valid conflict log table name
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log1');
+SELECT subname, subconflictlogtable FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogtable
+------------------------+-----------------------
+ regress_conflict_test2 | regress_conflict_log1
+(1 row)
+
+--ok - pg_relation_is_publishable should return false for conflict log table
+SELECT pg_relation_is_publishable('public.regress_conflict_log1');
+ pg_relation_is_publishable
+----------------------------
+ f
+(1 row)
+
+--ok - alter subscription with conflict log table = NONE
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = NONE);
+-- should return NULL, meaning the table was dropped
+SELECT to_regclass('public.regress_conflict_log1');
+ to_regclass
+-------------
+
+(1 row)
+
+SELECT subname, subconflictlogtable FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogtable
+------------------------+---------------------
+ regress_conflict_test2 |
+(1 row)
+
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
RESET SESSION AUTHORIZATION;
+DROP SCHEMA clt;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
DROP ROLE regress_subscription_user3;
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index ef0c298d2df..416847f081b 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -365,7 +365,115 @@ COMMIT;
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG TABLE TESTS
+--
+
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+
+-- fail - conflict_log_table specified when table already exists
+CREATE TABLE public.regress_conflict_log_temp (id int);
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log_temp');
+DROP TABLE public.regress_conflict_log_temp;
+
+-- ok - conflict_log_table creation with CREATE SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+
+-- check metadata in pg_subscription
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- check if the table exists and has the correct schema (11 columns)
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attnum > 0;
+
+-- check a specific column type (e.g., key_tuple should be JSON)
+SELECT format_type(atttypid, atttypmod) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attname = 'key_tuple';
+
+\dRs+
+
+-- ok - adding conflict_log_table with ALTER SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log2');
+
+-- check metadata after ALTER
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- ok - change the conflict log table name for an existing subscription that already had one
+CREATE SCHEMA clt;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'clt.regress_conflict_log3');
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+\dRs+
+
+-- check the new table was created and the old table was dropped
+SELECT count(*) FROM pg_class WHERE relname = 'regress_conflict_log2';
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'clt.regress_conflict_log3'::regclass AND attnum > 0;
+
+-- ok (NOTICE) - set conflict_log_table to one already used by this subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'clt.regress_conflict_log3');
+
+-- fail - try to publish the conflict_log_table
+CREATE PUBLICATION pub FOR TABLE clt.regress_conflict_log3;
+
+-- suppress warning that depends on wal_level
+SET client_min_messages = 'ERROR';
+
+-- ok - conflict_log_table should not be published with ALL TABLE
+CREATE PUBLICATION pub FOR TABLES IN SCHEMA clt;
+SELECT * FROM pg_publication_tables WHERE pubname = 'pub';
+\dt+ clt.regress_conflict_log3
+DROP PUBLICATION pub;
+
+-- fail - set conflict_log_table to one already used by a different subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log1');
+
+-- ok - dropping subscription also drops the log table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the table was dropped
+SELECT to_regclass('public.regress_conflict_log1');
+
+-- ok - dropping subscription when the log table was manually dropped first
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+DROP TABLE public.regress_conflict_log1;
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the subscription was dropped successfully
+SELECT subname FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- ok - create subscription with conflict_log_table = NONE
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = NONE);
+SELECT subname, subconflictlogtable FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+--ok - alter subscription with valid conflict log table name
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log1');
+SELECT subname, subconflictlogtable FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+--ok - pg_relation_is_publishable should return false for conflict log table
+SELECT pg_relation_is_publishable('public.regress_conflict_log1');
+
+--ok - alter subscription with conflict log table = NONE
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = NONE);
+
+-- should return NULL, meaning the table was dropped
+SELECT to_regclass('public.regress_conflict_log1');
+SELECT subname, subconflictlogtable FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+
RESET SESSION AUTHORIZATION;
+DROP SCHEMA clt;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
DROP ROLE regress_subscription_user3;
--
2.49.0
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-12 03:49 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
3 siblings, 1 reply; 912+ messages in thread
From: shveta malik @ 2025-12-12 03:49 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Thu, Dec 11, 2025 at 7:49 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Thu, Dec 11, 2025 at 5:57 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> >
> > On Thu, Dec 11, 2025 at 5:10 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > On Thu, Dec 11, 2025 at 5:04 PM shveta malik <shveta.malik@gmail.com> wrote:
> > >
> > > > 2)
> > > > When we do below:
> > > > alter subscription sub1 SET (conflict_log_table=clt2);
> > > >
> > > > the previous conflict log table is dropped. Is this behavior
> > > > intentional and discussed/concluded earlier? It’s possible that a user
> > > > may want to create a new conflict log table for future events while
> > > > still retaining the old one for analysis. If the subscription itself
> > > > is dropped, then dropping the CLT makes sense, but I’m not sure this
> > > > behavior is intended for ALTER SUBSCRIPTION. I do understand that
> > > > once we unlink CLT from subscription, later even DROP subscription
> > > > cannot drop it, but user can always drop it when not needed.
> > > >
> > > > If we plan to keep existing behavior, it should be clearly documented
> > > > in a CAUTION section, and the command should explicitly log the table
> > > > drop.
> > >
> > > Yeah we discussed this behavior and the conclusion was we would
> > > document this behavior and its user's responsibility to take necessary
> > > backup of the conflict log table data if they are setting a new log
> > > table or NONE for the subscription.
> > >
> >
> > +1. If we don't do this then it will be difficult to track for
> > postgres or users the previous conflict history tables.
>
> Right, it makes sense.
Okay, right.
>
> Attached patch fixed most of the open comments
> 1) \dRs+ now show the schema qualified name
> 2) Now key_tuple and replica_identify tuple both are add in conflict
> log tuple wherever applicable
> 3) Refactored the code so that we can define the conflict log table
> schema only once in the header file and both create_conflict_log_table
> and ValidateConflictLogTable use it.
>
> I was considering the interdependence between the subscription and the
> conflict log table (CLT). IMHO, it would be logical to establish the
> subscription as dependent on the CLT. This way, if someone attempts to
> drop the CLT, the system would recognize the dependency of the
> subscription and prevent the drop unless the subscription is removed
> first or the CASCADE option is used.
>
> However, while investigating this, I encountered an error [1] stating
> that global objects are not supported in this context. This indicates
> that global objects cannot be made dependent on local objects.
> Although making an object dependent on global/shared objects is
> possible for certain types of shared objects [2], this is not our main
> objective.
>
> We do not need to make the CLT dependent on the subscription because
> the table can be dropped when the subscription is dropped anyway and
> we are already doing it as part of drop subscription as well as alter
> subscription when CLT is set to NONE or a different table. Therefore,
> extending the functionality of shared dependency is unnecessary for
> this purpose.
>
> Thoughts?
I believe the recommendation to create a dependency was meant to
prevent the table from being accidentally dropped during a DROP SCHEMA
or DROP TABLE operation. That risk still remains, regardless of the
fact that dropping or altering a subscription will result in the table
removal. I will give this more thought and let you know if anything
comes to mind.
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-12 04:11 Dilip Kumar <dilipbalaut@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-12-12 04:11 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Fri, Dec 12, 2025 at 9:19 AM shveta malik <shveta.malik@gmail.com> wrote:
>
> On Thu, Dec 11, 2025 at 7:49 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > We do not need to make the CLT dependent on the subscription because
> > the table can be dropped when the subscription is dropped anyway and
> > we are already doing it as part of drop subscription as well as alter
> > subscription when CLT is set to NONE or a different table. Therefore,
> > extending the functionality of shared dependency is unnecessary for
> > this purpose.
> >
> > Thoughts?
>
> I believe the recommendation to create a dependency was meant to
> prevent the table from being accidentally dropped during a DROP SCHEMA
> or DROP TABLE operation. That risk still remains, regardless of the
> fact that dropping or altering a subscription will result in the table
> removal. I will give this more thought and let you know if anything
> comes to mind.
I mean we can register the dependency of subscriber on table and that
will prevent dropping the tables via DROP TABLE/DROP SCHEMA, but what
I do not like is the internal error[1] in doDeletion() when someone
will try to DROP TABLE CLT CASCADE;
I suggest an alternative approach for handling this: implement a check
within the ALTER/DROP table commands. If the table is a CLT (using
IsConflictLogTable() to verify), these operations should be
disallowed. This would enhance the robustness of CLT handling by
entirely preventing external drop/alter actions. What are your
thoughts on this solution? And let's also see what Amit and Sawada-san
think about this solution.
[1]
doDeletion()
{
....
/*
* These global object types are not supported here.
*/
case AuthIdRelationId:
case DatabaseRelationId:
case TableSpaceRelationId:
case SubscriptionRelationId:
case ParameterAclRelationId:
elog(ERROR, "global objects cannot be deleted by doDeletion");
break;
}
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-12 04:32 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: shveta malik @ 2025-12-12 04:32 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Fri, Dec 12, 2025 at 9:42 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Fri, Dec 12, 2025 at 9:19 AM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > On Thu, Dec 11, 2025 at 7:49 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > We do not need to make the CLT dependent on the subscription because
> > > the table can be dropped when the subscription is dropped anyway and
> > > we are already doing it as part of drop subscription as well as alter
> > > subscription when CLT is set to NONE or a different table. Therefore,
> > > extending the functionality of shared dependency is unnecessary for
> > > this purpose.
> > >
> > > Thoughts?
> >
> > I believe the recommendation to create a dependency was meant to
> > prevent the table from being accidentally dropped during a DROP SCHEMA
> > or DROP TABLE operation. That risk still remains, regardless of the
> > fact that dropping or altering a subscription will result in the table
> > removal. I will give this more thought and let you know if anything
> > comes to mind.
>
> I mean we can register the dependency of subscriber on table and that
> will prevent dropping the tables via DROP TABLE/DROP SCHEMA, but what
> I do not like is the internal error[1] in doDeletion() when someone
> will try to DROP TABLE CLT CASCADE;
>
Yes, I understand that part.
> I suggest an alternative approach for handling this: implement a check
> within the ALTER/DROP table commands. If the table is a CLT (using
> IsConflictLogTable() to verify), these operations should be
> disallowed. This would enhance the robustness of CLT handling by
> entirely preventing external drop/alter actions. What are your
> thoughts on this solution? And let's also see what Amit and Sawada-san
> think about this solution.
I had similar thoughts, but was unsure how this should behave when a
user runs DROP SCHEMA … CASCADE. We can’t simply block the entire
operation with an error just because the schema contains a CLT, but we
also shouldn’t allow it to proceed without notifying the user that the
schema includes a CLT.
>
> [1]
> doDeletion()
> {
> ....
> /*
> * These global object types are not supported here.
> */
> case AuthIdRelationId:
> case DatabaseRelationId:
> case TableSpaceRelationId:
> case SubscriptionRelationId:
> case ParameterAclRelationId:
> elog(ERROR, "global objects cannot be deleted by doDeletion");
> break;
> }
>
> --
> Regards,
> Dilip Kumar
> Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-12 04:58 Dilip Kumar <dilipbalaut@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 0 replies; 912+ messages in thread
From: Dilip Kumar @ 2025-12-12 04:58 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Fri, Dec 12, 2025 at 10:02 AM shveta malik <shveta.malik@gmail.com> wrote:
>
> On Fri, Dec 12, 2025 at 9:42 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Fri, Dec 12, 2025 at 9:19 AM shveta malik <shveta.malik@gmail.com> wrote:
> > >
> > > On Thu, Dec 11, 2025 at 7:49 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > >
> > > > We do not need to make the CLT dependent on the subscription because
> > > > the table can be dropped when the subscription is dropped anyway and
> > > > we are already doing it as part of drop subscription as well as alter
> > > > subscription when CLT is set to NONE or a different table. Therefore,
> > > > extending the functionality of shared dependency is unnecessary for
> > > > this purpose.
> > > >
> > > > Thoughts?
> > >
> > > I believe the recommendation to create a dependency was meant to
> > > prevent the table from being accidentally dropped during a DROP SCHEMA
> > > or DROP TABLE operation. That risk still remains, regardless of the
> > > fact that dropping or altering a subscription will result in the table
> > > removal. I will give this more thought and let you know if anything
> > > comes to mind.
> >
> > I mean we can register the dependency of subscriber on table and that
> > will prevent dropping the tables via DROP TABLE/DROP SCHEMA, but what
> > I do not like is the internal error[1] in doDeletion() when someone
> > will try to DROP TABLE CLT CASCADE;
> >
>
> Yes, I understand that part.
>
> > I suggest an alternative approach for handling this: implement a check
> > within the ALTER/DROP table commands. If the table is a CLT (using
> > IsConflictLogTable() to verify), these operations should be
> > disallowed. This would enhance the robustness of CLT handling by
> > entirely preventing external drop/alter actions. What are your
> > thoughts on this solution? And let's also see what Amit and Sawada-san
> > think about this solution.
>
> I had similar thoughts, but was unsure how this should behave when a
> user runs DROP SCHEMA … CASCADE. We can’t simply block the entire
> operation with an error just because the schema contains a CLT, but we
> also shouldn’t allow it to proceed without notifying the user that the
> schema includes a CLT.
I understand your concern about whether this restriction is
appropriate, particularly when using DROP SCHEMA … CASCADE is.
However, considering the logical dependency where the subscription
relies on the table (CLT), expecting DROP SCHEMA … CASCADE to drop the
CLT implies it should also drop the dependent subscription, which is
not permitted. Therefore, a more appropriate behavior would be to
issue an error message stating that the table is a conflict log table
and that subscriber "<subname>" depends on it. This message should
instruct the user to either drop the subscription or reset the
conflict log table before proceeding with the drop operation.
OTOH, we can simply let the CLT get dropped and altered and document
this behavior so that it is the user's responsibility to not to
drop/alter the CLT otherwise conflict logging will be skipped as we
have now. While thinking more I feel it might be better to keep it
simple as we have now instead of overcomplicating it?
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-12 05:57 vignesh C <vignesh21@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
3 siblings, 0 replies; 912+ messages in thread
From: vignesh C @ 2025-12-12 05:57 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, 11 Dec 2025 at 19:50, Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Thu, Dec 11, 2025 at 5:57 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> >
> > On Thu, Dec 11, 2025 at 5:10 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > On Thu, Dec 11, 2025 at 5:04 PM shveta malik <shveta.malik@gmail.com> wrote:
> > >
> > > > 2)
> > > > When we do below:
> > > > alter subscription sub1 SET (conflict_log_table=clt2);
> > > >
> > > > the previous conflict log table is dropped. Is this behavior
> > > > intentional and discussed/concluded earlier? It’s possible that a user
> > > > may want to create a new conflict log table for future events while
> > > > still retaining the old one for analysis. If the subscription itself
> > > > is dropped, then dropping the CLT makes sense, but I’m not sure this
> > > > behavior is intended for ALTER SUBSCRIPTION. I do understand that
> > > > once we unlink CLT from subscription, later even DROP subscription
> > > > cannot drop it, but user can always drop it when not needed.
> > > >
> > > > If we plan to keep existing behavior, it should be clearly documented
> > > > in a CAUTION section, and the command should explicitly log the table
> > > > drop.
> > >
> > > Yeah we discussed this behavior and the conclusion was we would
> > > document this behavior and its user's responsibility to take necessary
> > > backup of the conflict log table data if they are setting a new log
> > > table or NONE for the subscription.
> > >
> >
> > +1. If we don't do this then it will be difficult to track for
> > postgres or users the previous conflict history tables.
>
> Right, it makes sense.
>
> Attached patch fixed most of the open comments
> 1) \dRs+ now show the schema qualified name
> 2) Now key_tuple and replica_identify tuple both are add in conflict
> log tuple wherever applicable
> 3) Refactored the code so that we can define the conflict log table
> schema only once in the header file and both create_conflict_log_table
> and ValidateConflictLogTable use it.
>
> I was considering the interdependence between the subscription and the
> conflict log table (CLT). IMHO, it would be logical to establish the
> subscription as dependent on the CLT. This way, if someone attempts to
> drop the CLT, the system would recognize the dependency of the
> subscription and prevent the drop unless the subscription is removed
> first or the CASCADE option is used.
>
> However, while investigating this, I encountered an error [1] stating
> that global objects are not supported in this context. This indicates
> that global objects cannot be made dependent on local objects.
> Although making an object dependent on global/shared objects is
> possible for certain types of shared objects [2], this is not our main
> objective.
>
> We do not need to make the CLT dependent on the subscription because
> the table can be dropped when the subscription is dropped anyway and
> we are already doing it as part of drop subscription as well as alter
> subscription when CLT is set to NONE or a different table. Therefore,
> extending the functionality of shared dependency is unnecessary for
> this purpose.
I noticed an inconsistency in the checks that prevent adding a
conflict log table to a publication. At creation time, we explicitly
reject attempts to publish a conflict log table:
/* Can't be conflict log table */
if (IsConflictLogTable(RelationGetRelid(targetrel)))
ereport(ERROR,
(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
errmsg("cannot add relation \"%s.%s\" to publication",
get_namespace_name(RelationGetNamespace(targetrel)),
RelationGetRelationName(targetrel)),
errdetail("This operation is not supported for conflict
log tables.")));
However, the restriction can be bypassed through a sequence of table
renames like below:
-- Set up logical replication
CREATE PUBLICATION pub_all;
CREATE SUBSCRIPTION sub1 CONNECTION '...' PUBLICATION pub_all WITH
(conflict_log_table = 'conflict');
-- Rename the conflict log table
ALTER TABLE conflict RENAME TO conflict1;
-- Now this succeeds:
CREATE PUBLICATION pub1 FOR TABLE conflict1;
-- Rename it back
ALTER TABLE conflict1 RENAME TO conflict;
\dRp+ pub1
Publication pub1
...
Tables:
public.conflict
Thus, although we prohibit publishing the conflict log table directly,
a publication can still end up referencing it through renaming. This
is inconsistent with the invariant the code attempts to enforce.
Should we extend the checks to handle renames so that a conflict log
table can never end up in a publication?
Alternatively, should the creation-time restriction be relaxed if this
case is acceptable?
If the invariant should be enforced, should we also prevent renaming a
conflict-log table into a published table's name?
Thoughts?
Regards,
Vignesh
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-12 09:33 Amit Kapila <amit.kapila16@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
3 siblings, 2 replies; 912+ messages in thread
From: Amit Kapila @ 2025-12-12 09:33 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, Dec 11, 2025 at 7:49 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> I was considering the interdependence between the subscription and the
> conflict log table (CLT). IMHO, it would be logical to establish the
> subscription as dependent on the CLT. This way, if someone attempts to
> drop the CLT, the system would recognize the dependency of the
> subscription and prevent the drop unless the subscription is removed
> first or the CASCADE option is used.
>
> However, while investigating this, I encountered an error [1] stating
> that global objects are not supported in this context. This indicates
> that global objects cannot be made dependent on local objects.
>
What we need here is an equivalent of DEPENDENCY_INTERNAL for database
objects. For example, consider following case:
postgres=# create table t1(c1 int primary key);
CREATE TABLE
postgres=# \d+ t1
Table "public.t1"
Column | Type | Collation | Nullable | Default | Storage |
Compression | Stats target | Description
--------+---------+-----------+----------+---------+---------+-------------+--------------+-------------
c1 | integer | | not null | | plain |
| |
Indexes:
"t1_pkey" PRIMARY KEY, btree (c1)
Publications:
"pub1"
Not-null constraints:
"t1_c1_not_null" NOT NULL "c1"
Access method: heap
postgres=# drop index t1_pkey;
ERROR: cannot drop index t1_pkey because constraint t1_pkey on table
t1 requires it
HINT: You can drop constraint t1_pkey on table t1 instead.
Here, the PK index is created as part for CREATE TABLE operation and
pk_index is not allowed to be dropped independently.
> Although making an object dependent on global/shared objects is
> possible for certain types of shared objects [2], this is not our main
> objective.
>
As per my understanding from the above example, we need something like
that only for shared object subscription and (internally created)
table.
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-12 10:03 shveta malik <shveta.malik@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: shveta malik @ 2025-12-12 10:03 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>
On Fri, Dec 12, 2025 at 3:04 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Thu, Dec 11, 2025 at 7:49 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > I was considering the interdependence between the subscription and the
> > conflict log table (CLT). IMHO, it would be logical to establish the
> > subscription as dependent on the CLT. This way, if someone attempts to
> > drop the CLT, the system would recognize the dependency of the
> > subscription and prevent the drop unless the subscription is removed
> > first or the CASCADE option is used.
> >
> > However, while investigating this, I encountered an error [1] stating
> > that global objects are not supported in this context. This indicates
> > that global objects cannot be made dependent on local objects.
> >
>
> What we need here is an equivalent of DEPENDENCY_INTERNAL for database
> objects. For example, consider following case:
> postgres=# create table t1(c1 int primary key);
> CREATE TABLE
> postgres=# \d+ t1
> Table "public.t1"
> Column | Type | Collation | Nullable | Default | Storage |
> Compression | Stats target | Description
> --------+---------+-----------+----------+---------+---------+-------------+--------------+-------------
> c1 | integer | | not null | | plain |
> | |
> Indexes:
> "t1_pkey" PRIMARY KEY, btree (c1)
> Publications:
> "pub1"
> Not-null constraints:
> "t1_c1_not_null" NOT NULL "c1"
> Access method: heap
> postgres=# drop index t1_pkey;
> ERROR: cannot drop index t1_pkey because constraint t1_pkey on table
> t1 requires it
> HINT: You can drop constraint t1_pkey on table t1 instead.
>
> Here, the PK index is created as part for CREATE TABLE operation and
> pk_index is not allowed to be dropped independently.
>
> > Although making an object dependent on global/shared objects is
> > possible for certain types of shared objects [2], this is not our main
> > objective.
> >
>
> As per my understanding from the above example, we need something like
> that only for shared object subscription and (internally created)
> table.
>
+1
~~
Few comments for v11:
1)
+#include "executor/spi.h"
+#include "replication/conflict.h"
+#include "utils/fmgroids.h"
+#include "utils/regproc.h"
subscriptioncmds.c compiles without the above inclusions.
2)
postgres=# create subscription sub3 connection '...' publication pub1
WITH(conflict_log_table='pg_temp.clt');
NOTICE: created replication slot "sub3" on publisher
CREATE SUBSCRIPTION
Should we restrict clt creation in pg_temp?
3)
+ /* Fetch the eixsting conflict table table information. */
typos: eixsting->existing,
table table -> table
4)
AlterSubscription():
+ values[Anum_pg_subscription_subconflictlognspid - 1] =
+ ObjectIdGetDatum(nspid);
+
+ if (relname != NULL)
+ values[Anum_pg_subscription_subconflictlogtable - 1] =
+ CStringGetTextDatum(relname);
+ else
+ nulls[Anum_pg_subscription_subconflictlogtable - 1] =
+ true;
Should we move the nspid setting inside 'if(relname != NULL)' block?
5)
Is there a way to reset/remove conflict_log_table? I did not see any
such handling in AlterSubscription? It gives error:
postgres=# alter subscription sub3 set (conflict_log_table='');
ERROR: invalid name syntax
6)
+char *
+get_subscription_conflict_log_table(Oid subid, Oid *nspid)
+{
+ HeapTuple tup;
+ Datum datum;
+ bool isnull;
+ char *relname = NULL;
+ Form_pg_subscription subform;
+
+ *nspid = InvalidOid;
+
+ tup = SearchSysCache1(SUBSCRIPTIONOID, ObjectIdGetDatum(subid));
+
+ if (!HeapTupleIsValid(tup))
+ return NULL;
Should we have elog(ERROR) here for cache lookup failure? Callers like
AlterSubscription, DropSubscription lock the sub entry, so it being
missing at this stage is not normal. I have not seen all the callers
though.
7)
+#include "access/htup.h"
+#include "access/skey.h"
+#include "access/table.h"
+#include "catalog/pg_attribute.h"
+#include "catalog/indexing.h"
+#include "catalog/namespace.h"
+#include "catalog/pg_namespace.h"
+#include "catalog/pg_type.h"
+#include "executor/spi.h"
+#include "utils/array.h"
conflict.c compiles without above inclusions.
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-14 10:21 Dilip Kumar <dilipbalaut@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-12-14 10:21 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Fri, Dec 12, 2025 at 3:04 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Thu, Dec 11, 2025 at 7:49 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > I was considering the interdependence between the subscription and the
> > conflict log table (CLT). IMHO, it would be logical to establish the
> > subscription as dependent on the CLT. This way, if someone attempts to
> > drop the CLT, the system would recognize the dependency of the
> > subscription and prevent the drop unless the subscription is removed
> > first or the CASCADE option is used.
> >
> > However, while investigating this, I encountered an error [1] stating
> > that global objects are not supported in this context. This indicates
> > that global objects cannot be made dependent on local objects.
> >
>
> What we need here is an equivalent of DEPENDENCY_INTERNAL for database
> objects. For example, consider following case:
> postgres=# create table t1(c1 int primary key);
> CREATE TABLE
> postgres=# \d+ t1
> Table "public.t1"
> Column | Type | Collation | Nullable | Default | Storage |
> Compression | Stats target | Description
> --------+---------+-----------+----------+---------+---------+-------------+--------------+-------------
> c1 | integer | | not null | | plain |
> | |
> Indexes:
> "t1_pkey" PRIMARY KEY, btree (c1)
> Publications:
> "pub1"
> Not-null constraints:
> "t1_c1_not_null" NOT NULL "c1"
> Access method: heap
> postgres=# drop index t1_pkey;
> ERROR: cannot drop index t1_pkey because constraint t1_pkey on table
> t1 requires it
> HINT: You can drop constraint t1_pkey on table t1 instead.
>
> Here, the PK index is created as part for CREATE TABLE operation and
> pk_index is not allowed to be dropped independently.
>
> > Although making an object dependent on global/shared objects is
> > possible for certain types of shared objects [2], this is not our main
> > objective.
> >
>
> As per my understanding from the above example, we need something like
> that only for shared object subscription and (internally created)
> table.
Yeah that seems to be exactly what we want, so I tried doing that by
recording DEPENDENCY_INTERNAL dependency of CLT on subscription[1] and
it is behaving as we want[2]. And while dropping the subscription or
altering CLT we can delete internal dependency so that CLT get dropped
automatically[3]
I will send an updated patch after testing a few more scenarios and
fixing other pending issues.
[1]
+ ObjectAddressSet(myself, RelationRelationId, relid);
+ ObjectAddressSet(subaddr, SubscriptionRelationId, subid);
+ recordDependencyOn(&myself, &subaddr, DEPENDENCY_INTERNAL);
[2]
postgres[670778]=# DROP TABLE myschema.conflict_log_history2;
ERROR: 2BP01: cannot drop table myschema.conflict_log_history2
because subscription sub requires it
HINT: You can drop subscription sub instead.
LOCATION: findDependentObjects, dependency.c:788
postgres[670778]=#
[3]
ObjectAddressSet(object, SubscriptionRelationId, subid);
performDeletion(&object, DROP_CASCADE
PERFORM_DELETION_INTERNAL |
PERFORM_DELETION_SKIP_ORIGINAL);
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-14 15:46 Dilip Kumar <dilipbalaut@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 3 replies; 912+ messages in thread
From: Dilip Kumar @ 2025-12-14 15:46 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Sun, Dec 14, 2025 at 3:51 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Fri, Dec 12, 2025 at 3:04 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> >
> > On Thu, Dec 11, 2025 at 7:49 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > I was considering the interdependence between the subscription and the
> > > conflict log table (CLT). IMHO, it would be logical to establish the
> > > subscription as dependent on the CLT. This way, if someone attempts to
> > > drop the CLT, the system would recognize the dependency of the
> > > subscription and prevent the drop unless the subscription is removed
> > > first or the CASCADE option is used.
> > >
> > > However, while investigating this, I encountered an error [1] stating
> > > that global objects are not supported in this context. This indicates
> > > that global objects cannot be made dependent on local objects.
> > >
> >
> > What we need here is an equivalent of DEPENDENCY_INTERNAL for database
> > objects. For example, consider following case:
> > postgres=# create table t1(c1 int primary key);
> > CREATE TABLE
> > postgres=# \d+ t1
> > Table "public.t1"
> > Column | Type | Collation | Nullable | Default | Storage |
> > Compression | Stats target | Description
> > --------+---------+-----------+----------+---------+---------+-------------+--------------+-------------
> > c1 | integer | | not null | | plain |
> > | |
> > Indexes:
> > "t1_pkey" PRIMARY KEY, btree (c1)
> > Publications:
> > "pub1"
> > Not-null constraints:
> > "t1_c1_not_null" NOT NULL "c1"
> > Access method: heap
> > postgres=# drop index t1_pkey;
> > ERROR: cannot drop index t1_pkey because constraint t1_pkey on table
> > t1 requires it
> > HINT: You can drop constraint t1_pkey on table t1 instead.
> >
> > Here, the PK index is created as part for CREATE TABLE operation and
> > pk_index is not allowed to be dropped independently.
> >
> > > Although making an object dependent on global/shared objects is
> > > possible for certain types of shared objects [2], this is not our main
> > > objective.
> > >
> >
> > As per my understanding from the above example, we need something like
> > that only for shared object subscription and (internally created)
> > table.
>
> Yeah that seems to be exactly what we want, so I tried doing that by
> recording DEPENDENCY_INTERNAL dependency of CLT on subscription[1] and
> it is behaving as we want[2]. And while dropping the subscription or
> altering CLT we can delete internal dependency so that CLT get dropped
> automatically[3]
>
> I will send an updated patch after testing a few more scenarios and
> fixing other pending issues.
>
> [1]
> + ObjectAddressSet(myself, RelationRelationId, relid);
> + ObjectAddressSet(subaddr, SubscriptionRelationId, subid);
> + recordDependencyOn(&myself, &subaddr, DEPENDENCY_INTERNAL);
>
>
> [2]
> postgres[670778]=# DROP TABLE myschema.conflict_log_history2;
> ERROR: 2BP01: cannot drop table myschema.conflict_log_history2
> because subscription sub requires it
> HINT: You can drop subscription sub instead.
> LOCATION: findDependentObjects, dependency.c:788
> postgres[670778]=#
>
> [3]
> ObjectAddressSet(object, SubscriptionRelationId, subid);
> performDeletion(&object, DROP_CASCADE
> PERFORM_DELETION_INTERNAL |
> PERFORM_DELETION_SKIP_ORIGINAL);
>
>
Here is the patch which implements the dependency and fixes other
comments from Shveta.
--
Regards,
Dilip Kumar
Google
Attachments:
[application/octet-stream] v12-0001-Add-configurable-conflict-log-table-for-Logical-.patch (126.0K, ../../CAFiTN-v=ANapYvRK+SOy2wJb4CSuD6Vb6_bTGuReM9Dv+3tucA@mail.gmail.com/2-v12-0001-Add-configurable-conflict-log-table-for-Logical-.patch)
download | inline diff:
From 1a7b7ac1a913dc129871f77e1c23cbe9615bd2fa Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 12 Nov 2025 10:43:19 +0530
Subject: [PATCH v12] Add configurable conflict log table for Logical
Replication
This patch adds a feature to provide a structured, queryable record of all
logical replication conflicts. The current approach of logging conflicts as
plain text in the server logs makes it difficult to query, analyze, and
use for external monitoring and automation.
This patch addresses these limitations by introducing a configurable
conflict_log_table option in the CREATE SUBSCRIPTION command. Key design
decisions include:
User-Managed Table: The conflict log is stored in a user-managed table
rather than a system catalog.
Structured Data: Conflict details, including the original and remote tuples,
are stored in JSON columns, providing a flexible format to accommodate different
table schemas.
Comprehensive Information: The log table captures essential attributes such as
local and remote transaction IDs, LSNs, commit timestamps, and conflict type,
providing a complete record for post-mortem analysis.
This feature will make logical replication conflicts easier to monitor and manage,
significantly improving the overall resilience and operability of replication setups.
The conflict log tables will not be included in a publication, even if the publication
is configured to include ALL TABLES or ALL TABLES IN SCHEMA.
Note: A single remote tuple may conflict with multiple local tuples when conflict type
is CT_MULTIPLE_UNIQUE_CONFLICTS, so for handling this case we create a single row in
conflict log table with respect to each remote conflict tuple even if it conflicts with
multiple local tuples and we store the multiple conflict tuples as a single JSON array
element in format as
[ { "xid": "1001", "commit_ts": "...", "origin": "...", "tuple": {...} }, ... ]
We can extract the elements of the local tuples from the conflict log table row
as given in below example.
SELECT remote_xid, relname, remote_origin, local_conflicts[1] ->> 'xid' AS local_xid,
local_conflicts[1] ->> 'tuple' AS local_tuple
FROM myschema.conflict_log_history2;
remote_xid | relname | remote_origin | local_xid | local_tuple
------------+----------+---------------+-----------+---------------------
760 | test | pg_16406 | 771 | {"a":1,"b":10}
765 | conf_tab | pg_16406 | 775 | {"a":2,"b":2,"c":2}
---
src/backend/catalog/pg_publication.c | 26 +-
src/backend/commands/subscriptioncmds.c | 307 ++++++++++-
src/backend/replication/logical/conflict.c | 569 ++++++++++++++++++++-
src/backend/replication/logical/launcher.c | 1 +
src/backend/replication/logical/worker.c | 37 +-
src/backend/utils/cache/lsyscache.c | 38 ++
src/bin/psql/describe.c | 24 +-
src/bin/psql/tab-complete.in.c | 8 +-
src/include/catalog/pg_subscription.h | 5 +
src/include/commands/subscriptioncmds.h | 2 +
src/include/replication/conflict.h | 32 ++
src/include/replication/worker_internal.h | 7 +
src/include/utils/lsyscache.h | 1 +
src/test/regress/expected/subscription.out | 370 ++++++++++----
src/test/regress/sql/subscription.sql | 114 +++++
15 files changed, 1413 insertions(+), 128 deletions(-)
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index 7aa3f179924..9f84e02b7ef 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -31,6 +31,7 @@
#include "catalog/pg_publication_rel.h"
#include "catalog/pg_type.h"
#include "commands/publicationcmds.h"
+#include "commands/subscriptioncmds.h"
#include "funcapi.h"
#include "utils/array.h"
#include "utils/builtins.h"
@@ -85,6 +86,15 @@ check_publication_add_relation(Relation targetrel)
errmsg("cannot add relation \"%s\" to publication",
RelationGetRelationName(targetrel)),
errdetail("This operation is not supported for unlogged tables.")));
+
+ /* Can't be conflict log table */
+ if (IsConflictLogTable(RelationGetRelid(targetrel)))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot add relation \"%s.%s\" to publication",
+ get_namespace_name(RelationGetNamespace(targetrel)),
+ RelationGetRelationName(targetrel)),
+ errdetail("This operation is not supported for conflict log tables.")));
}
/*
@@ -145,6 +155,13 @@ is_publishable_class(Oid relid, Form_pg_class reltuple)
/*
* Another variant of is_publishable_class(), taking a Relation.
+ *
+ * Note: Conflict log tables are not publishable. However, we intentionally
+ * skip this check here because this function is called for every change and
+ * performing this check during every change publication is costly. To ensure
+ * unpublishable entries are ignored without incurring performance overhead,
+ * tuples inserted into the conflict log table uses the HEAP_INSERT_NO_LOGICAL
+ * flag. This allows the decoding layer to bypass these entries automatically.
*/
bool
is_publishable_relation(Relation rel)
@@ -169,7 +186,10 @@ pg_relation_is_publishable(PG_FUNCTION_ARGS)
tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
if (!HeapTupleIsValid(tuple))
PG_RETURN_NULL();
- result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple));
+
+ /* Subscription conflict log tables are not published */
+ result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple)) &&
+ !IsConflictLogTable(relid);
ReleaseSysCache(tuple);
PG_RETURN_BOOL(result);
}
@@ -890,7 +910,9 @@ GetAllPublicationRelations(char relkind, bool pubviaroot)
Form_pg_class relForm = (Form_pg_class) GETSTRUCT(tuple);
Oid relid = relForm->oid;
+ /* Subscription conflict log tables are not published */
if (is_publishable_class(relid, relForm) &&
+ !IsConflictLogTable(relid) &&
!(relForm->relispartition && pubviaroot))
result = lappend_oid(result, relid);
}
@@ -1018,7 +1040,7 @@ GetSchemaPublicationRelations(Oid schemaid, PublicationPartOpt pub_partopt)
Oid relid = relForm->oid;
char relkind;
- if (!is_publishable_class(relid, relForm))
+ if (!is_publishable_class(relid, relForm) || IsConflictLogTable(relid))
continue;
relkind = get_rel_relkind(relid);
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index abbcaff0838..b044ed70a2a 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -15,16 +15,19 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/htup_details.h"
#include "access/table.h"
#include "access/twophase.h"
#include "access/xact.h"
#include "catalog/catalog.h"
#include "catalog/dependency.h"
+#include "catalog/heap.h"
#include "catalog/indexing.h"
#include "catalog/namespace.h"
#include "catalog/objectaccess.h"
#include "catalog/objectaddress.h"
+#include "catalog/pg_am_d.h"
#include "catalog/pg_authid_d.h"
#include "catalog/pg_database_d.h"
#include "catalog/pg_subscription.h"
@@ -34,6 +37,7 @@
#include "commands/event_trigger.h"
#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "miscadmin.h"
#include "nodes/makefuncs.h"
#include "pgstat.h"
@@ -51,6 +55,7 @@
#include "utils/lsyscache.h"
#include "utils/memutils.h"
#include "utils/pg_lsn.h"
+#include "utils/regproc.h"
#include "utils/syscache.h"
/*
@@ -75,6 +80,7 @@
#define SUBOPT_MAX_RETENTION_DURATION 0x00008000
#define SUBOPT_LSN 0x00010000
#define SUBOPT_ORIGIN 0x00020000
+#define SUBOPT_CONFLICT_LOG_TABLE 0x00040000
/* check if the 'val' has 'bits' set */
#define IsSet(val, bits) (((val) & (bits)) == (bits))
@@ -103,6 +109,7 @@ typedef struct SubOpts
bool retaindeadtuples;
int32 maxretention;
char *origin;
+ char *conflictlogtable;
XLogRecPtr lsn;
} SubOpts;
@@ -135,7 +142,8 @@ static List *merge_publications(List *oldpublist, List *newpublist, bool addpub,
static void ReportSlotConnectionError(List *rstates, Oid subid, char *slotname, char *err);
static void CheckAlterSubOption(Subscription *sub, const char *option,
bool slot_needs_update, bool isTopLevel);
-
+static void create_conflict_log_table(Oid namespaceId, char *conflictrel,
+ Oid subid);
/*
* Common option parsing function for CREATE and ALTER SUBSCRIPTION commands.
@@ -191,6 +199,8 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->maxretention = 0;
if (IsSet(supported_opts, SUBOPT_ORIGIN))
opts->origin = pstrdup(LOGICALREP_ORIGIN_ANY);
+ if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_TABLE))
+ opts->conflictlogtable = NULL;
/* Parse options */
foreach(lc, stmt_options)
@@ -402,6 +412,19 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->specified_opts |= SUBOPT_LSN;
opts->lsn = lsn;
}
+ else if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_TABLE) &&
+ strcmp(defel->defname, "conflict_log_table") == 0)
+ {
+ if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_LOG_TABLE))
+ errorConflictingDefElem(defel, pstate);
+
+ opts->specified_opts |= SUBOPT_CONFLICT_LOG_TABLE;
+ opts->conflictlogtable = defGetString(defel);
+
+ /* Setting conflict_log_table = NONE is treated as no table. */
+ if (strcmp(opts->conflictlogtable, "none") == 0)
+ opts->conflictlogtable = NULL;
+ }
else
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
@@ -599,6 +622,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bits32 supported_opts;
SubOpts opts = {0};
AclResult aclresult;
+ Oid conflictlogtable_nspid = InvalidOid;
+ char *conflictlogtable = NULL;
/*
* Parse and check options.
@@ -612,7 +637,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
SUBOPT_DISABLE_ON_ERR | SUBOPT_PASSWORD_REQUIRED |
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
- SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN);
+ SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_TABLE);
parse_subscription_options(pstate, stmt->options, supported_opts, &opts);
/*
@@ -747,6 +773,34 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
values[Anum_pg_subscription_suborigin - 1] =
CStringGetTextDatum(opts.origin);
+ /*
+ * If a conflict log table name is specified, parse the schema and table
+ * name from the string. Store the namespace OID and the table name in
+ * the pg_subscription catalog tuple.
+ */
+ if (opts.conflictlogtable)
+ {
+ List *names;
+
+ /* Explicitly check for empty string before any processing. */
+ if (opts.conflictlogtable[0] == '\0')
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("conflict log table name cannot be empty"),
+ errhint("Provide a valid table name or omit the parameter.")));
+
+ names = stringToQualifiedNameList(opts.conflictlogtable, NULL);
+
+ conflictlogtable_nspid =
+ QualifiedNameGetCreationNamespace(names, &conflictlogtable);
+ values[Anum_pg_subscription_subconflictlognspid - 1] =
+ ObjectIdGetDatum(conflictlogtable_nspid);
+ values[Anum_pg_subscription_subconflictlogtable - 1] =
+ CStringGetTextDatum(conflictlogtable);
+ }
+ else
+ nulls[Anum_pg_subscription_subconflictlogtable - 1] = true;
+
tup = heap_form_tuple(RelationGetDescr(rel), values, nulls);
/* Insert tuple into catalog. */
@@ -768,6 +822,11 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
ReplicationOriginNameForLogicalRep(subid, InvalidOid, originname, sizeof(originname));
replorigin_create(originname);
+ /* If a conflict log table name is given then create the table. */
+ if (opts.conflictlogtable)
+ create_conflict_log_table(conflictlogtable_nspid, conflictlogtable,
+ subid);
+
/*
* Connect to remote side to execute requested commands and fetch table
* and sequence info.
@@ -1410,7 +1469,8 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
SUBOPT_MAX_RETENTION_DURATION |
- SUBOPT_ORIGIN);
+ SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_TABLE);
parse_subscription_options(pstate, stmt->options,
supported_opts, &opts);
@@ -1665,6 +1725,96 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
origin = opts.origin;
}
+ if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_LOG_TABLE))
+ {
+ Oid nspid = InvalidOid;
+ Oid old_nspid = InvalidOid;
+ char *old_relname = NULL;
+ char *relname = NULL;
+ List *names = NIL;
+
+ if (opts.conflictlogtable != NULL)
+ {
+ /* Explicitly check for empty string before any processing. */
+ if (opts.conflictlogtable[0] == '\0')
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("conflict log table name cannot be empty"),
+ errhint("Provide a valid table name or omit the parameter.")));
+
+ names = stringToQualifiedNameList(opts.conflictlogtable,
+ NULL);
+ nspid = QualifiedNameGetCreationNamespace(names, &relname);
+ }
+
+ /* Fetch the existing conflict table information. */
+ old_relname =
+ get_subscription_conflict_log_table(subid, &old_nspid);
+
+ /*
+ * If the subscription already uses this conflict log table
+ * and it exists, just issue a notice.
+ */
+ if (old_relname != NULL && relname != NULL
+ && (strcmp(old_relname, relname) == 0) &&
+ old_nspid == nspid &&
+ OidIsValid(get_relname_relid(old_relname, old_nspid)))
+ {
+ char *nspname = get_namespace_name(nspid);
+
+ ereport(NOTICE,
+ (errmsg("\"%s.%s\" is already in use as the conflict log table for this subscription",
+ nspname, relname)));
+ pfree(nspname);
+ }
+ else
+ {
+ ObjectAddress object;
+
+ /*
+ * Conflict log tables are recorded as internal
+ * dependencies of the subscription. Before
+ * associating a new table, drop the existing table to
+ * avoid stale or orphaned relations.
+ *
+ * XXX: At present, only conflict log tables are
+ * managed this way. In future if we introduce
+ * additional internal dependencies, we may need
+ * a targeted deletion to avoid deletion of any
+ * other objects.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId, subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
+ /*
+ * Need to create a new table if a new name was
+ * provided.
+ */
+ if (relname != NULL)
+ create_conflict_log_table(nspid, relname, subid);
+
+ values[Anum_pg_subscription_subconflictlognspid - 1] =
+ ObjectIdGetDatum(nspid);
+
+ if (relname != NULL)
+ values[Anum_pg_subscription_subconflictlogtable - 1] =
+ CStringGetTextDatum(relname);
+ else
+ nulls[Anum_pg_subscription_subconflictlogtable - 1] =
+ true;
+
+ replaces[Anum_pg_subscription_subconflictlognspid - 1] =
+ true;
+ replaces[Anum_pg_subscription_subconflictlogtable - 1] =
+ true;
+ }
+
+ if (old_relname != NULL)
+ pfree(old_relname);
+ }
+
update_tuple = true;
break;
}
@@ -2027,6 +2177,7 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
Form_pg_subscription form;
List *rstates;
bool must_use_password;
+ ObjectAddress object;
/*
* The launcher may concurrently start a new worker for this subscription.
@@ -2184,6 +2335,19 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
/* Clean up dependencies */
deleteSharedDependencyRecordsFor(SubscriptionRelationId, subid, 0);
+ /*
+ * Conflict log tables are recorded as internal dependencies of the
+ * subscription. We must drop the dependent objects before the
+ * subscription itself is removed. By using
+ * PERFORM_DELETION_SKIP_ORIGINAL, we ensure that only the conflict log
+ * table is reaped while the subscription remains for the final deletion
+ * step.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId, subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
/* Remove any associated relation synchronization states. */
RemoveSubscriptionRel(subid, InvalidOid);
@@ -3188,3 +3352,140 @@ defGetStreamingMode(DefElem *def)
def->defname)));
return LOGICALREP_STREAM_OFF; /* keep compiler quiet */
}
+
+/*
+ * Builds the TupleDesc for the conflict log table.
+ */
+static TupleDesc
+create_conflict_log_table_tupdesc(void)
+{
+ TupleDesc tupdesc;
+ int i;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_CONFLICT_ATTR_NUM);
+
+ for (i = 0; i < MAX_CONFLICT_ATTR_NUM; i++)
+ {
+ Oid type_oid = ConflictLogSchema[i].atttypid;
+
+ /* Special handling for the JSON array type for proper TupleDescInitEntry call */
+ if (type_oid == JSONARRAYOID)
+ type_oid = get_array_type(JSONOID);
+
+ TupleDescInitEntry(tupdesc, i + 1,
+ ConflictLogSchema[i].attname,
+ type_oid,
+ -1, 0);
+ }
+
+ return BlessTupleDesc(tupdesc);
+}
+
+/*
+ * Create conflict log table.
+ *
+ * The subscription owner becomes the owner of this table and has all
+ * privileges on it.
+ */
+static void
+create_conflict_log_table(Oid namespaceId, char *conflictrel, Oid subid)
+{
+ TupleDesc tupdesc;
+ Oid relid;
+ ObjectAddress myself;
+ ObjectAddress subaddr;
+
+ /* Report an error if the specified conflict log table already exists. */
+ if (OidIsValid(get_relname_relid(conflictrel, namespaceId)))
+ ereport(ERROR,
+ (errcode(ERRCODE_DUPLICATE_TABLE),
+ errmsg("cannot create conflict log table \"%s.%s\" because a table with that name already exists",
+ get_namespace_name(namespaceId), conflictrel),
+ errhint("Use a different name for the conflict log table or drop the existing table.")));
+
+ /*
+ * Conflict log tables must be permanent relations. Disallow creation in
+ * temporary namespaces to ensure the same.
+ */
+ if (isTempNamespace(namespaceId))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot create conflict log table \"%s\" in a temporary namespace",
+ conflictrel),
+ errhint("Use a permanent schema.")));
+
+ /* Build the tuple descriptor for the new table. */
+ tupdesc = create_conflict_log_table_tupdesc();
+
+ /* Create conflict log table. */
+ relid = heap_create_with_catalog(conflictrel,
+ namespaceId,
+ 0,
+ InvalidOid,
+ InvalidOid,
+ InvalidOid,
+ GetUserId(),
+ HEAP_TABLE_AM_OID,
+ tupdesc,
+ NIL,
+ RELKIND_RELATION,
+ RELPERSISTENCE_PERMANENT,
+ false,
+ false,
+ ONCOMMIT_NOOP,
+ (Datum) 0,
+ false,
+ false,
+ false,
+ InvalidOid,
+ NULL);
+
+ /*
+ * Establish an internal dependency between the conflict log table and
+ * the subscription. By using DEPENDENCY_INTERNAL, we ensure the table
+ * is automatically reaped when the subscription is dropped. This also
+ * prevents the table from being dropped independently unless the
+ * subscription itself is removed.
+ */
+ ObjectAddressSet(myself, RelationRelationId, relid);
+ ObjectAddressSet(subaddr, SubscriptionRelationId, subid);
+ recordDependencyOn(&myself, &subaddr, DEPENDENCY_INTERNAL);
+
+ /* Release tuple descriptor memory. */
+ FreeTupleDesc(tupdesc);
+}
+
+/*
+ * Check if the specified relation is used as a conflict log table by any
+ * subscription.
+ */
+bool
+IsConflictLogTable(Oid relid)
+{
+ Relation rel;
+ TableScanDesc scan;
+ HeapTuple tup;
+ bool is_clt = false;
+
+ rel = table_open(SubscriptionRelationId, AccessShareLock);
+ scan = table_beginscan_catalog(rel, 0, NULL);
+
+ while (HeapTupleIsValid(tup = heap_getnext(scan, ForwardScanDirection)))
+ {
+ Form_pg_subscription subform = (Form_pg_subscription) GETSTRUCT(tup);
+ Oid nspid;
+ char *relname;
+
+ relname = get_subscription_conflict_log_table(subform->oid, &nspid);
+ if (relname && relid == get_relname_relid(relname, nspid))
+ {
+ is_clt = true;
+ break;
+ }
+ }
+
+ table_endscan(scan);
+ table_close(rel, AccessShareLock);
+
+ return is_clt;
+}
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 16695592265..1d357805eca 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -15,13 +15,21 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/tableam.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "pgstat.h"
#include "replication/conflict.h"
#include "replication/worker_internal.h"
#include "storage/lmgr.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/lsyscache.h"
+#include "utils/pg_lsn.h"
+#include "utils/jsonb.h"
+
+#define MAX_LOCAL_CONFLICT_INFO_ATTRS 5
static const char *const ConflictTypeNames[] = {
[CT_INSERT_EXISTS] = "insert_exists",
@@ -50,8 +58,27 @@ static char *build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *localslot,
TupleTableSlot *remoteslot,
Oid indexoid);
+static void build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull);
static char *build_index_value_desc(EState *estate, Relation localrel,
TupleTableSlot *slot, Oid indexoid);
+static Datum tuple_table_slot_to_json_datum(TupleTableSlot *slot);
+static Datum tuple_table_slot_to_indextup_json(EState *estate,
+ Relation localrel,
+ Oid replica_index,
+ TupleTableSlot *slot);
+static TupleDesc build_conflict_tupledesc(void);
+static Datum build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples);
+static void prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot);
/*
* Get the xmin and commit timestamp data (origin and timestamp) associated
@@ -106,6 +133,7 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
TupleTableSlot *remoteslot, List *conflicttuples)
{
Relation localrel = relinfo->ri_RelationDesc;
+ Relation conflictlogrel = GetConflictLogTableRel();
StringInfoData err_detail;
initStringInfo(&err_detail);
@@ -120,6 +148,37 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
conflicttuple->ts,
&err_detail);
+ /* Insert conflict details to conflict log table. */
+ if (conflictlogrel)
+ {
+ if (ValidateConflictLogTable(conflictlogrel))
+ {
+ /*
+ * Prepare the conflict log tuple. If the error level is below
+ * ERROR, insert it immediately. Otherwise, defer the insertion to
+ * a new transaction after the current one aborts, ensuring the
+ * insertion of the log tuple is not rolled back.
+ */
+ prepare_conflict_log_tuple(estate,
+ relinfo->ri_RelationDesc,
+ conflictlogrel,
+ type,
+ searchslot,
+ conflicttuples,
+ remoteslot);
+ if (elevel < ERROR)
+ InsertConflictLogTuple(conflictlogrel);
+ }
+ else
+ ereport(WARNING,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("Conflict log table \"%s.%s\" structure changed, skipping insertion",
+ get_namespace_name(RelationGetNamespace(conflictlogrel)),
+ RelationGetRelationName(conflictlogrel)));
+
+ table_close(conflictlogrel, RowExclusiveLock);
+ }
+
pgstat_report_subscription_conflict(MySubscription->oid, type);
ereport(elevel,
@@ -162,6 +221,141 @@ InitConflictIndexes(ResultRelInfo *relInfo)
relInfo->ri_onConflictArbiterIndexes = uniqueIndexes;
}
+/*
+ * GetConflictLogTableRel
+ *
+ * Get the information of the specific conflict log table defined in
+ * pg_subscription and opens the relation for insertion. The caller is
+ * responsible for closing the returned relation handle.
+ */
+Relation
+GetConflictLogTableRel(void)
+{
+ Oid nspid;
+ Oid conflictlogrelid;
+ Relation conflictlogrel = NULL;
+ char *conflictlogtable;
+
+ /* If conflict log table is not set for the subscription just return. */
+ conflictlogtable = get_subscription_conflict_log_table(
+ MyLogicalRepWorker->subid, &nspid);
+ if (conflictlogtable == NULL)
+ return NULL;
+
+ conflictlogrelid = get_relname_relid(conflictlogtable, nspid);
+ if (OidIsValid(conflictlogrelid))
+ conflictlogrel = table_open(conflictlogrelid, RowExclusiveLock);
+
+ /* Conflict log table is dropped or not accessible. */
+ if (conflictlogrel == NULL)
+ ereport(WARNING,
+ (errcode(ERRCODE_UNDEFINED_TABLE),
+ errmsg("conflict log table \"%s.%s\" does not exist",
+ get_namespace_name(nspid), conflictlogtable)));
+
+ pfree(conflictlogtable);
+
+ return conflictlogrel;
+}
+
+/*
+ * InsertConflictLogTuple
+ *
+ * Insert conflict log tuple into the conflict log table. It uses
+ * HEAP_INSERT_NO_LOGICAL to explicitly block logical decoding
+ * of the tuple inserted into the conflict log table.
+ */
+void
+InsertConflictLogTuple(Relation conflictlogrel)
+{
+ int options = HEAP_INSERT_NO_LOGICAL;
+
+ /* A valid tuple must be prepared and stored in MyLogicalRepWorker. */
+ Assert(MyLogicalRepWorker->conflict_log_tuple != NULL);
+
+ heap_insert(conflictlogrel, MyLogicalRepWorker->conflict_log_tuple,
+ GetCurrentCommandId(true), options, NULL);
+
+ /* Free conflict log tuple. */
+ heap_freetuple(MyLogicalRepWorker->conflict_log_tuple);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+}
+
+/*
+ * ValidateConflictLogTable - Validate conflict log table
+ *
+ * Validate whether the conflict log table is still suitable for considering as
+ * conflict log table.
+ */
+bool
+ValidateConflictLogTable(Relation rel)
+{
+ Relation pg_attribute;
+ HeapTuple atup;
+ ScanKeyData scankey;
+ SysScanDesc scan;
+ Form_pg_attribute attForm;
+ int attcnt = 0;
+ bool tbl_ok = true;
+
+ /*
+ * Check whether the table definition including its column names, data
+ * types, and column ordering meets the requirements for conflict log
+ * table.
+ */
+ pg_attribute = table_open(AttributeRelationId, AccessShareLock);
+ ScanKeyInit(&scankey,
+ Anum_pg_attribute_attrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationGetRelid(rel)));
+
+ scan = systable_beginscan(pg_attribute, AttributeRelidNumIndexId, true,
+ SnapshotSelf, 1, &scankey);
+
+ /* We only need to check up to MAX_CONFLICT_ATTR_NUM attributes */
+ while (HeapTupleIsValid(atup = systable_getnext(scan)))
+ {
+ const ConflictLogColumnDef *expected;
+ int schema_idx;
+
+ attForm = (Form_pg_attribute) GETSTRUCT(atup);
+
+ /* Skip system columns and dropped columns */
+ if (attForm->attnum < 1 || attForm->attisdropped)
+ continue;
+
+ attcnt++;
+
+ /* attnum 1 corresponds to index 0 in ConflictLogSchema */
+ schema_idx = attForm->attnum - 1;
+
+ /* Check against the central schema definition */
+ if (schema_idx >= MAX_CONFLICT_ATTR_NUM)
+ {
+ /* Found an extra column beyond the required set */
+ tbl_ok = false;
+ break;
+ }
+
+ expected = &ConflictLogSchema[schema_idx];
+
+ if (attForm->atttypid != expected->atttypid ||
+ strcmp(NameStr(attForm->attname), expected->attname) != 0)
+ {
+ tbl_ok = false;
+ break;
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_attribute, AccessShareLock);
+
+ if (attcnt != MAX_CONFLICT_ATTR_NUM || !tbl_ok)
+ return false;
+
+ return true;
+}
+
/*
* Add SQLSTATE error code to the current conflict report.
*/
@@ -472,6 +666,40 @@ build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
return tuple_value.data;
}
+/*
+ * Helper function to extract the "raw" index key Datums and their null flags
+ * from a TupleTableSlot, given an already open index descriptor.
+ * This is the reusable core logic.
+ */
+static void
+build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull)
+{
+ TupleTableSlot *tableslot = slot;
+
+ /*
+ * If the slot is a virtual slot, copy it into a heap tuple slot as
+ * FormIndexDatum only works with heap tuple slots.
+ */
+ if (TTS_IS_VIRTUAL(slot))
+ {
+ /* Slot is created within the EState's tuple table */
+ tableslot = table_slot_create(localrel, &estate->es_tupleTable);
+ tableslot = ExecCopySlot(tableslot, slot);
+ }
+
+ /*
+ * Initialize ecxt_scantuple for potential use in FormIndexDatum
+ */
+ GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+
+ /* Form the index datums */
+ FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values,
+ isnull);
+}
+
/*
* Helper functions to construct a string describing the contents of an index
* entry. See BuildIndexValueDescription for details.
@@ -487,41 +715,336 @@ build_index_value_desc(EState *estate, Relation localrel, TupleTableSlot *slot,
Relation indexDesc;
Datum values[INDEX_MAX_KEYS];
bool isnull[INDEX_MAX_KEYS];
- TupleTableSlot *tableslot = slot;
- if (!tableslot)
+ if (!slot)
return NULL;
Assert(CheckRelationOidLockedByMe(indexoid, RowExclusiveLock, true));
indexDesc = index_open(indexoid, NoLock);
- /*
- * If the slot is a virtual slot, copy it into a heap tuple slot as
- * FormIndexDatum only works with heap tuple slots.
- */
- if (TTS_IS_VIRTUAL(slot))
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+
+ index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+
+ index_close(indexDesc, NoLock);
+
+ return index_value;
+}
+
+/*
+ * tuple_table_slot_to_json_datum
+ *
+ * Helper function to convert a TupleTableSlot to Jsonb.
+ */
+static Datum
+tuple_table_slot_to_json_datum(TupleTableSlot *slot)
+{
+ HeapTuple tuple;
+ Datum datum;
+ Datum json;
+
+ Assert(slot != NULL);
+
+ tuple = ExecCopySlotHeapTuple(slot);
+ datum = heap_copy_tuple_as_datum(tuple, slot->tts_tupleDescriptor);
+
+ json = DirectFunctionCall1(row_to_json, datum);
+ heap_freetuple(tuple);
+
+ return json;
+}
+
+/*
+ * tuple_table_slot_to_indextup_json
+ *
+ * Fetch replica identity key from the tuple table slot and convert into a
+ * jsonb datum.
+ */
+static Datum
+tuple_table_slot_to_indextup_json(EState *estate, Relation localrel,
+ Oid indexid, TupleTableSlot *slot)
+{
+ Relation indexDesc;
+ Datum values[INDEX_MAX_KEYS];
+ bool isnull[INDEX_MAX_KEYS];
+ HeapTuple tuple;
+ TupleDesc tupdesc;
+ Datum datum;
+
+ Assert(slot != NULL);
+
+ Assert(CheckRelationOidLockedByMe(indexid, RowExclusiveLock, true));
+
+ indexDesc = index_open(indexid, NoLock);
+
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+ tupdesc = RelationGetDescr(indexDesc);
+
+ /* Bless the tupdesc so it can be looked up by row_to_json. */
+ BlessTupleDesc(tupdesc);
+
+ /* Form the replica identity tuple. */
+ tuple = heap_form_tuple(tupdesc, values, isnull);
+ datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ index_close(indexDesc, NoLock);
+
+ /* Convert to a JSONB datum. */
+ return DirectFunctionCall1(row_to_json, datum);
+}
+
+/*
+ * Initialize the tuple descriptor for local conflict info.
+ */
+static TupleDesc
+build_conflict_tupledesc(void)
+{
+ TupleDesc tupdesc;
+ int attno = 1;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ TupleDescInitEntry(tupdesc, (AttrNumber) attno++, "xid",
+ XIDOID, -1, 0);
+ TupleDescInitEntry(tupdesc, (AttrNumber) attno++, "commit_ts",
+ TIMESTAMPTZOID, -1, 0);
+ TupleDescInitEntry(tupdesc, (AttrNumber) attno++, "origin",
+ TEXTOID, -1, 0);
+ TupleDescInitEntry(tupdesc, (AttrNumber) attno++, "key",
+ JSONOID, -1, 0);
+ TupleDescInitEntry(tupdesc, (AttrNumber) attno, "tuple",
+ JSONOID, -1, 0);
+
+ BlessTupleDesc(tupdesc);
+
+ Assert(attno == MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ return tupdesc;
+}
+
+/*
+ * Builds the local conflicts JSONB array column from the list of
+ * ConflictTupleInfo objects.
+ *
+ * Example output structure:
+ * [ { "xid": "1001", "commit_ts": "...", "origin": "...", "tuple": {...} }, ... ]
+ */
+static Datum
+build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples)
+{
+ ListCell *lc;
+ List *json_datums = NIL; /* List to hold the row_to_json results (type json) */
+ Datum *json_datum_array;
+ bool *json_null_array;
+ Datum json_array_datum;
+ int num_conflicts;
+ int i;
+ int16 typlen;
+ bool typbyval;
+ char typalign;
+ TupleDesc tupdesc;
+
+ /* Build local conflicts tuple descriptor. */
+ tupdesc = build_conflict_tupledesc();
+
+ /* Process local conflict tuple list and prepare an array of JSON. */
+ foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
{
- tableslot = table_slot_create(localrel, &estate->es_tupleTable);
- tableslot = ExecCopySlot(tableslot, slot);
+ Datum values[MAX_LOCAL_CONFLICT_INFO_ATTRS];
+ bool nulls[MAX_LOCAL_CONFLICT_INFO_ATTRS];
+ char *origin_name = NULL;
+ HeapTuple tuple;
+ Datum json_datum;
+ int attno;
+
+ memset(values, 0, sizeof(Datum) * MAX_LOCAL_CONFLICT_INFO_ATTRS);
+ memset(nulls, 0, sizeof(bool) * MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ attno = 0;
+ values[attno++] = TransactionIdGetDatum(conflicttuple->xmin);
+
+ if (conflicttuple->ts)
+ values[attno++] = TimestampTzGetDatum(conflicttuple->ts);
+ else
+ nulls[attno++] = true;
+
+ if (conflicttuple->origin != InvalidRepOriginId)
+ replorigin_by_oid(conflicttuple->origin, true, &origin_name);
+
+ /* Store empty string if origin name for the tuple is NULL. */
+ if (origin_name != NULL)
+ values[attno++] = CStringGetTextDatum(origin_name);
+ else
+ nulls[attno++] = true;
+
+ /*
+ * Add the conflicting key values in the case of a unique constraint
+ * violation.
+ */
+ if (conflict_type == CT_INSERT_EXISTS ||
+ conflict_type == CT_UPDATE_EXISTS ||
+ conflict_type == CT_MULTIPLE_UNIQUE_CONFLICTS)
+ {
+ Oid indexoid = conflicttuple->indexoid;
+
+ Assert(OidIsValid(indexoid) && conflicttuple->slot &&
+ CheckRelationOidLockedByMe(indexoid, RowExclusiveLock,
+ true));
+ values[attno++] =
+ tuple_table_slot_to_indextup_json(estate, rel,
+ indexoid,
+ conflicttuple->slot);
+ }
+ else
+ nulls[attno++] = true;
+
+ /* Convert conflicting tuple to JSON datum. */
+ if (conflicttuple->slot)
+ values[attno] = tuple_table_slot_to_json_datum(conflicttuple->slot);
+ else
+ nulls[attno] = true;
+
+ Assert(attno + 1 == MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ tuple = heap_form_tuple(tupdesc, values, nulls);
+
+ json_datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ /*
+ * Build the higher level JSON datum in format described in function
+ * header.
+ */
+ json_datum = DirectFunctionCall1(row_to_json, json_datum);
+
+ /* Done with the temporary tuple. */
+ heap_freetuple(tuple);
+
+ /* Add to the array element. */
+ json_datums = lappend(json_datums, (void *) json_datum);
}
- /*
- * Initialize ecxt_scantuple for potential use in FormIndexDatum when
- * index expressions are present.
- */
- GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+ num_conflicts = list_length(json_datums);
- /*
- * The values/nulls arrays passed to BuildIndexValueDescription should be
- * the results of FormIndexDatum, which are the "raw" input to the index
- * AM.
- */
- FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values, isnull);
+ json_datum_array = (Datum *) palloc(num_conflicts * sizeof(Datum));
+ json_null_array = (bool *) palloc0(num_conflicts * sizeof(bool));
- index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+ i = 0;
+ foreach(lc, json_datums)
+ {
+ json_datum_array[i] = (Datum) lfirst(lc);
+ i++;
+ }
- index_close(indexDesc, NoLock);
+ /* Construct the json[] array Datum. */
+ get_typlenbyvalalign(JSONOID, &typlen, &typbyval, &typalign);
+ json_array_datum = PointerGetDatum(construct_array(json_datum_array,
+ num_conflicts,
+ JSONOID,
+ typlen,
+ typbyval,
+ typalign));
+ pfree(json_datum_array);
+ pfree(json_null_array);
+
+ return json_array_datum;
+}
- return index_value;
+/*
+ * prepare_conflict_log_tuple
+ *
+ * This routine prepares a tuple detailing a conflict encountered during
+ * logical replication. The prepared tuple will be stored in
+ * MyLogicalRepWorker->conflict_log_tuple which should be inserted into the
+ * conflict log table by calling InsertConflictLogTuple.
+ */
+static void
+prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot)
+{
+ Datum values[MAX_CONFLICT_ATTR_NUM];
+ bool nulls[MAX_CONFLICT_ATTR_NUM];
+ int attno;
+ char *remote_origin = NULL;
+ MemoryContext oldctx;
+
+ Assert(MyLogicalRepWorker->conflict_log_tuple == NULL);
+
+ /* Initialize values and nulls arrays. */
+ memset(values, 0, sizeof(Datum) * MAX_CONFLICT_ATTR_NUM);
+ memset(nulls, 0, sizeof(bool) * MAX_CONFLICT_ATTR_NUM);
+
+ /* Populate the values and nulls arrays. */
+ attno = 0;
+ values[attno++] = ObjectIdGetDatum(RelationGetRelid(rel));
+
+ values[attno++] =
+ CStringGetTextDatum(get_namespace_name(RelationGetNamespace(rel)));
+
+ values[attno++] = CStringGetTextDatum(RelationGetRelationName(rel));
+
+ values[attno++] = CStringGetTextDatum(ConflictTypeNames[conflict_type]);
+
+ if (TransactionIdIsValid(remote_xid))
+ values[attno++] = TransactionIdGetDatum(remote_xid);
+ else
+ nulls[attno++] = true;
+
+ values[attno++] = LSNGetDatum(remote_final_lsn);
+
+ if (remote_commit_ts > 0)
+ values[attno++] = TimestampTzGetDatum(remote_commit_ts);
+ else
+ nulls[attno++] = true;
+
+ if (replorigin_session_origin != InvalidRepOriginId)
+ replorigin_by_oid(replorigin_session_origin, true, &remote_origin);
+
+ if (remote_origin != NULL)
+ values[attno++] = CStringGetTextDatum(remote_origin);
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(searchslot))
+ {
+ Oid replica_index = GetRelationIdentityOrPK(rel);
+
+ /*
+ * If the table has a valid replica identity index, build the index
+ * json datum from key value. Otherwise, construct it from the complete
+ * tuple in REPLICA IDENTITY FULL cases.
+ */
+ if (OidIsValid(replica_index))
+ values[attno++] = tuple_table_slot_to_indextup_json(estate, rel,
+ replica_index,
+ searchslot);
+ else
+ values[attno++] = tuple_table_slot_to_json_datum(searchslot);
+ }
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(remoteslot))
+ values[attno++] = tuple_table_slot_to_json_datum(remoteslot);
+ else
+ nulls[attno++] = true;
+
+ values[attno] = build_local_conflicts_json_array(estate, rel,
+ conflict_type,
+ conflicttuples);
+
+ Assert(attno + 1 == MAX_CONFLICT_ATTR_NUM);
+
+ oldctx = MemoryContextSwitchTo(ApplyContext);
+ MyLogicalRepWorker->conflict_log_tuple =
+ heap_form_tuple(RelationGetDescr(conflictlogrel), values, nulls);
+ MemoryContextSwitchTo(oldctx);
}
diff --git a/src/backend/replication/logical/launcher.c b/src/backend/replication/logical/launcher.c
index 3991e1495d4..bc7e1d9ebde 100644
--- a/src/backend/replication/logical/launcher.c
+++ b/src/backend/replication/logical/launcher.c
@@ -477,6 +477,7 @@ retry:
worker->oldest_nonremovable_xid = retain_dead_tuples
? MyReplicationSlot->data.xmin
: InvalidTransactionId;
+ worker->conflict_log_tuple = NULL;
worker->last_lsn = InvalidXLogRecPtr;
TIMESTAMP_NOBEGIN(worker->last_send_time);
TIMESTAMP_NOBEGIN(worker->last_recv_time);
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index fc64476a9ef..e8f7ab3d5d6 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -482,7 +482,9 @@ static bool MySubscriptionValid = false;
static List *on_commit_wakeup_workers_subids = NIL;
bool in_remote_transaction = false;
-static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+TransactionId remote_xid = InvalidTransactionId;
+TimestampTz remote_commit_ts = 0;
/* fields valid only when processing streamed transaction */
static bool in_streamed_transaction = false;
@@ -1219,6 +1221,8 @@ apply_handle_begin(StringInfo s)
set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
remote_final_lsn = begin_data.final_lsn;
+ remote_commit_ts = begin_data.committime;
+ remote_xid = begin_data.xid;
maybe_start_skipping_changes(begin_data.final_lsn);
@@ -1745,6 +1749,10 @@ apply_handle_stream_start(StringInfo s)
/* extract XID of the top-level transaction */
stream_xid = logicalrep_read_stream_start(s, &first_segment);
+ remote_xid = stream_xid;
+ remote_final_lsn = InvalidXLogRecPtr;
+ remote_commit_ts = 0;
+
if (!TransactionIdIsValid(stream_xid))
ereport(ERROR,
(errcode(ERRCODE_PROTOCOL_VIOLATION),
@@ -5609,6 +5617,33 @@ start_apply(XLogRecPtr origin_startpos)
pgstat_report_subscription_error(MySubscription->oid,
MyLogicalRepWorker->type);
+ /*
+ * Insert any pending conflict log tuple under a new transaction.
+ */
+ if (MyLogicalRepWorker->conflict_log_tuple != NULL)
+ {
+ Relation conflictlogrel;
+
+ StartTransactionCommand();
+ PushActiveSnapshot(GetTransactionSnapshot());
+
+ /* Open conflict log table and insert the tuple. */
+ conflictlogrel = GetConflictLogTableRel();
+ if (ValidateConflictLogTable(conflictlogrel))
+ InsertConflictLogTuple(conflictlogrel);
+ else
+ ereport(WARNING,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("Conflict log table \"%s.%s\" structure changed, skipping insertion",
+ get_namespace_name(RelationGetNamespace(conflictlogrel)),
+ RelationGetRelationName(conflictlogrel)));
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+ table_close(conflictlogrel, RowExclusiveLock);
+
+ PopActiveSnapshot();
+ CommitTransactionCommand();
+ }
+
PG_RE_THROW();
}
}
diff --git a/src/backend/utils/cache/lsyscache.c b/src/backend/utils/cache/lsyscache.c
index 5aa7a26d95c..b9090f7d17d 100644
--- a/src/backend/utils/cache/lsyscache.c
+++ b/src/backend/utils/cache/lsyscache.c
@@ -3879,3 +3879,41 @@ get_subscription_name(Oid subid, bool missing_ok)
return subname;
}
+
+/*
+ * get_subscription_conflict_log_table
+ *
+ * Get conflict log table name and namespace id from subscription.
+ */
+char *
+get_subscription_conflict_log_table(Oid subid, Oid *nspid)
+{
+ HeapTuple tup;
+ Datum datum;
+ bool isnull;
+ char *relname = NULL;
+ Form_pg_subscription subform;
+
+ *nspid = InvalidOid;
+
+ tup = SearchSysCache1(SUBSCRIPTIONOID, ObjectIdGetDatum(subid));
+
+ if (!HeapTupleIsValid(tup))
+ elog(ERROR, "cache lookup failed for subscription %u", subid);
+
+ subform = (Form_pg_subscription) GETSTRUCT(tup);
+
+ /* Get conflict log table name. */
+ datum = SysCacheGetAttr(SUBSCRIPTIONOID,
+ tup,
+ Anum_pg_subscription_subconflictlogtable,
+ &isnull);
+ if (!isnull)
+ {
+ *nspid = subform->subconflictlognspid;
+ relname = pstrdup(TextDatumGetCString(datum));
+ }
+
+ ReleaseSysCache(tup);
+ return relname;
+}
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index 36f24502842..906167fe466 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -6806,7 +6806,7 @@ describeSubscriptions(const char *pattern, bool verbose)
printQueryOpt myopt = pset.popt;
static const bool translate_columns[] = {false, false, false, false,
false, false, false, false, false, false, false, false, false, false,
- false, false, false, false};
+ false, false, false, false, false};
if (pset.sversion < 100000)
{
@@ -6900,15 +6900,25 @@ describeSubscriptions(const char *pattern, bool verbose)
appendPQExpBuffer(&buf,
", subskiplsn AS \"%s\"\n",
gettext_noop("Skip LSN"));
+
+ /* Conflict log table is only supported in v19 and higher */
+ if (pset.sversion >= 190000)
+ appendPQExpBuffer(&buf,
+ ", (CASE\n"
+ " WHEN subconflictlogtable IS NULL THEN NULL\n"
+ " ELSE pg_catalog.quote_ident(n.nspname) || '.' ||"
+ " pg_catalog.quote_ident(subconflictlogtable::text)\n"
+ "END) AS \"%s\"\n",
+ gettext_noop("Conflict log table"));
}
/* Only display subscriptions in current database. */
- appendPQExpBufferStr(&buf,
- "FROM pg_catalog.pg_subscription\n"
- "WHERE subdbid = (SELECT oid\n"
- " FROM pg_catalog.pg_database\n"
- " WHERE datname = pg_catalog.current_database())");
-
+ appendPQExpBuffer(&buf,
+ "FROM pg_catalog.pg_subscription "
+ "LEFT JOIN pg_catalog.pg_namespace AS n ON subconflictlognspid = n.oid\n"
+ "WHERE subdbid = (SELECT oid\n"
+ " FROM pg_catalog.pg_database\n"
+ " WHERE datname = pg_catalog.current_database())");
if (!validateSQLNamePattern(&buf, pattern, true, false,
NULL, "subname", NULL,
NULL,
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index b1ff6f6cd94..00e45423879 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -2344,8 +2344,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("(", "PUBLICATION");
/* ALTER SUBSCRIPTION <name> SET ( */
else if (Matches("ALTER", "SUBSCRIPTION", MatchAny, MatchAnyN, "SET", "("))
- COMPLETE_WITH("binary", "disable_on_error", "failover",
- "max_retention_duration", "origin",
+ COMPLETE_WITH("binary", "conflict_log_table", "disable_on_error",
+ "failover", "max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
"synchronous_commit", "two_phase");
@@ -3828,8 +3828,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("WITH (");
/* Complete "CREATE SUBSCRIPTION <name> ... WITH ( <opt>" */
else if (Matches("CREATE", "SUBSCRIPTION", MatchAnyN, "WITH", "("))
- COMPLETE_WITH("binary", "connect", "copy_data", "create_slot",
- "disable_on_error", "enabled", "failover",
+ COMPLETE_WITH("binary", "conflict_log_table", "connect", "copy_data",
+ "create_slot", "disable_on_error", "enabled", "failover",
"max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index 55cb9b1eefa..f4526c15ec3 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -80,6 +80,8 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
bool subretaindeadtuples; /* True if dead tuples useful for
* conflict detection are retained */
+ Oid subconflictlognspid; /* Namespace Oid in which the conflict
+ * log table is created. */
int32 submaxretention; /* The maximum duration (in milliseconds)
* for which information useful for
@@ -105,6 +107,9 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
/* Only publish data originating from the specified origin */
text suborigin BKI_DEFAULT(LOGICALREP_ORIGIN_ANY);
+
+ /* Conflict log table name if specified */
+ text subconflictlogtable;
#endif
} FormData_pg_subscription;
diff --git a/src/include/commands/subscriptioncmds.h b/src/include/commands/subscriptioncmds.h
index fb4e26a51a4..6c062b0991f 100644
--- a/src/include/commands/subscriptioncmds.h
+++ b/src/include/commands/subscriptioncmds.h
@@ -36,4 +36,6 @@ extern void CheckSubDeadTupleRetention(bool check_guc, bool sub_disabled,
bool retention_active,
bool max_retention_set);
+extern bool IsConflictLogTable(Oid relid);
+
#endif /* SUBSCRIPTIONCMDS_H */
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index c8fbf9e51b8..c7e67bd300e 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -9,9 +9,12 @@
#ifndef CONFLICT_H
#define CONFLICT_H
+#include "access/htup.h"
#include "access/xlogdefs.h"
+#include "catalog/pg_type.h"
#include "nodes/pg_list.h"
#include "utils/timestamp.h"
+#include "utils/relcache.h"
/* Avoid including execnodes.h here */
typedef struct EState EState;
@@ -79,6 +82,32 @@ typedef struct ConflictTupleInfo
* conflicting local row occurred */
} ConflictTupleInfo;
+/* Structure to hold metadata for one column of the conflict log table */
+typedef struct ConflictLogColumnDef
+{
+ const char *attname; /* Column name */
+ Oid atttypid; /* Data type OID */
+} ConflictLogColumnDef;
+
+/* The single source of truth for the conflict log table schema */
+static const ConflictLogColumnDef ConflictLogSchema[] =
+{
+ { .attname = "relid", .atttypid = OIDOID },
+ { .attname = "schemaname", .atttypid = TEXTOID },
+ { .attname = "relname", .atttypid = TEXTOID },
+ { .attname = "conflict_type", .atttypid = TEXTOID },
+ { .attname = "remote_xid", .atttypid = XIDOID },
+ { .attname = "remote_commit_lsn",.atttypid = LSNOID },
+ { .attname = "remote_commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "remote_origin", .atttypid = TEXTOID },
+ { .attname = "replica_identity", .atttypid = JSONOID },
+ { .attname = "remote_tuple", .atttypid = JSONOID },
+ { .attname = "local_conflicts", .atttypid = JSONARRAYOID }
+};
+
+/* Define the count using the array size */
+#define MAX_CONFLICT_ATTR_NUM (sizeof(ConflictLogSchema) / sizeof(ConflictLogSchema[0]))
+
extern bool GetTupleTransactionInfo(TupleTableSlot *localslot,
TransactionId *xmin,
RepOriginId *localorigin,
@@ -89,4 +118,7 @@ extern void ReportApplyConflict(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *remoteslot,
List *conflicttuples);
extern void InitConflictIndexes(ResultRelInfo *relInfo);
+extern Relation GetConflictLogTableRel(void);
+extern void InsertConflictLogTuple(Relation conflictlogrel);
+extern bool ValidateConflictLogTable(Relation rel);
#endif
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index f081619f151..711c04c7297 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -101,6 +101,9 @@ typedef struct LogicalRepWorker
*/
TransactionId oldest_nonremovable_xid;
+ /* A conflict log tuple that is prepared but not yet inserted. */
+ HeapTuple conflict_log_tuple;
+
/* Stats. */
XLogRecPtr last_lsn;
TimestampTz last_send_time;
@@ -256,6 +259,10 @@ extern PGDLLIMPORT bool InitializingApplyWorker;
extern PGDLLIMPORT List *table_states_not_ready;
+extern XLogRecPtr remote_final_lsn;
+extern TimestampTz remote_commit_ts;
+extern TransactionId remote_xid;
+
extern void logicalrep_worker_attach(int slot);
extern LogicalRepWorker *logicalrep_worker_find(LogicalRepWorkerType wtype,
Oid subid, Oid relid,
diff --git a/src/include/utils/lsyscache.h b/src/include/utils/lsyscache.h
index 50fb149e9ac..3bebf04bf51 100644
--- a/src/include/utils/lsyscache.h
+++ b/src/include/utils/lsyscache.h
@@ -210,6 +210,7 @@ extern Oid get_publication_oid(const char *pubname, bool missing_ok);
extern char *get_publication_name(Oid pubid, bool missing_ok);
extern Oid get_subscription_oid(const char *subname, bool missing_ok);
extern char *get_subscription_name(Oid subid, bool missing_ok);
+extern char *get_subscription_conflict_log_table(Oid subid, Oid *nspid);
#define type_is_array(typid) (get_element_type(typid) != InvalidOid)
/* type_is_array_domain accepts both plain arrays and domains over arrays */
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index 327d1e7731f..f96687e107c 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -116,18 +116,18 @@ CREATE SUBSCRIPTION regress_testsub4 CONNECTION 'dbname=regress_doesnotexist' PU
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub4 SET (origin = any);
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
DROP SUBSCRIPTION regress_testsub3;
@@ -145,10 +145,10 @@ ALTER SUBSCRIPTION regress_testsub CONNECTION 'foobar';
ERROR: invalid connection string syntax: missing "=" after "foobar" in connection info string
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET PUBLICATION testpub2, testpub3 WITH (refresh = false);
@@ -157,10 +157,10 @@ ALTER SUBSCRIPTION regress_testsub SET (slot_name = 'newname');
ALTER SUBSCRIPTION regress_testsub SET (password_required = false);
ALTER SUBSCRIPTION regress_testsub SET (run_as_owner = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (password_required = true);
@@ -176,10 +176,10 @@ ERROR: unrecognized subscription parameter: "create_slot"
-- ok
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/12345');
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345 |
(1 row)
-- ok - with lsn = NONE
@@ -188,10 +188,10 @@ ALTER SUBSCRIPTION regress_testsub SKIP (lsn = NONE);
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/0');
ERROR: invalid WAL location (LSN): 0/0
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 |
(1 row)
BEGIN;
@@ -223,10 +223,10 @@ ALTER SUBSCRIPTION regress_testsub_foo SET (synchronous_commit = foobar);
ERROR: invalid value for parameter "synchronous_commit": "foobar"
HINT: Available values: local, remote_write, remote_apply, on, off.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
----------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+---------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000 |
(1 row)
-- rename back to keep the rest simple
@@ -255,19 +255,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (binary = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -279,27 +279,27 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = parallel);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- fail - publication already exists
@@ -314,10 +314,10 @@ ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refr
ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refresh = false);
ERROR: publication "testpub1" is already in subscription "regress_testsub"
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- fail - publication used more than once
@@ -332,10 +332,10 @@ ERROR: publication "testpub3" is not in subscription "regress_testsub"
-- ok - delete publications
ALTER SUBSCRIPTION regress_testsub DROP PUBLICATION testpub1, testpub2 WITH (refresh = false);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -371,19 +371,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- we can alter streaming when two_phase enabled
ALTER SUBSCRIPTION regress_testsub SET (streaming = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -393,10 +393,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -409,18 +409,18 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (disable_on_error = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -433,10 +433,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -450,19 +450,19 @@ NOTICE: max_retention_duration is ineffective when retain_dead_tuples is disabl
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- ok
ALTER SUBSCRIPTION regress_testsub SET (max_retention_duration = 0);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -517,7 +517,201 @@ COMMIT;
-- ok, owning it is enough for this stuff
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG TABLE TESTS
+--
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+-- fail - conflict_log_table specified when table already exists
+CREATE TABLE public.regress_conflict_log_temp (id int);
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log_temp');
+ERROR: cannot create conflict log table "public.regress_conflict_log_temp" because a table with that name already exists
+HINT: Use a different name for the conflict log table or drop the existing table.
+DROP TABLE public.regress_conflict_log_temp;
+-- ok - conflict_log_table creation with CREATE SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- check metadata in pg_subscription
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname | subconflictlogtable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test1 | regress_conflict_log1 | t
+(1 row)
+
+-- check if the table exists and has the correct schema (11 columns)
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attnum > 0;
+ count
+-------
+ 11
+(1 row)
+
+-- check a specific column type (e.g., remote_tuple should be JSON)
+SELECT format_type(atttypid, atttypmod) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attname = 'remote_tuple';
+ format_type
+-------------
+ json
+(1 row)
+
+\dRs+
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+------------------------------
+ regress_conflict_test1 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | public.regress_conflict_log1
+(1 row)
+
+-- ok - adding conflict_log_table with ALTER SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log2');
+-- check metadata after ALTER
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogtable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test2 | regress_conflict_log2 | t
+(1 row)
+
+-- ok - change the conflict log table name for an existing subscription that already had one
+CREATE SCHEMA clt;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'clt.regress_conflict_log3');
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogtable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test2 | regress_conflict_log3 | f
+(1 row)
+
+\dRs+
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+------------------------------
+ regress_conflict_test1 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | public.regress_conflict_log1
+ regress_conflict_test2 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | clt.regress_conflict_log3
+(2 rows)
+
+-- check the new table was created and the old table was dropped
+SELECT count(*) FROM pg_class WHERE relname = 'regress_conflict_log2';
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'clt.regress_conflict_log3'::regclass AND attnum > 0;
+ count
+-------
+ 11
+(1 row)
+
+-- ok (NOTICE) - set conflict_log_table to one already used by this subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'clt.regress_conflict_log3');
+NOTICE: "clt.regress_conflict_log3" is already in use as the conflict log table for this subscription
+-- fail - try to publish the conflict_log_table
+CREATE PUBLICATION pub FOR TABLE clt.regress_conflict_log3;
+ERROR: cannot add relation "clt.regress_conflict_log3" to publication
+DETAIL: This operation is not supported for conflict log tables.
+-- suppress warning that depends on wal_level
+SET client_min_messages = 'ERROR';
+-- ok - conflict_log_table should not be published with ALL TABLE
+CREATE PUBLICATION pub FOR TABLES IN SCHEMA clt;
+SELECT * FROM pg_publication_tables WHERE pubname = 'pub';
+ pubname | schemaname | tablename | attnames | rowfilter
+---------+------------+-----------+----------+-----------
+(0 rows)
+
+\dt+ clt.regress_conflict_log3
+ List of tables
+ Schema | Name | Type | Owner | Persistence | Size | Description
+--------+-----------------------+-------+---------------------------+-------------+---------+-------------
+ clt | regress_conflict_log3 | table | regress_subscription_user | permanent | 0 bytes |
+(1 row)
+
+DROP PUBLICATION pub;
+-- fail - set conflict_log_table to one already used by a different subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log1');
+ERROR: cannot create conflict log table "public.regress_conflict_log1" because a table with that name already exists
+HINT: Use a different name for the conflict log table or drop the existing table.
+-- ok - dropping subscription also drops the log table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the table was dropped
+SELECT to_regclass('public.regress_conflict_log1');
+ to_regclass
+-------------
+
+(1 row)
+
+-- fail - dropping log table manually not allowed
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+DROP TABLE public.regress_conflict_log1;
+ERROR: cannot drop table regress_conflict_log1 because subscription regress_conflict_test1 requires it
+HINT: You can drop subscription regress_conflict_test1 instead.
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the subscription was dropped successfully
+SELECT subname FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname
+---------
+(0 rows)
+
+-- ok - create subscription with conflict_log_table = NONE
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = NONE);
+SELECT subname, subconflictlogtable FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogtable
+------------------------+-----------------------
+ regress_conflict_test2 | regress_conflict_log3
+(1 row)
+
+-- ok - alter subscription with valid conflict log table name
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log1');
+SELECT subname, subconflictlogtable FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogtable
+------------------------+-----------------------
+ regress_conflict_test2 | regress_conflict_log1
+(1 row)
+
+-- ok - pg_relation_is_publishable should return false for conflict log table
+SELECT pg_relation_is_publishable('public.regress_conflict_log1');
+ pg_relation_is_publishable
+----------------------------
+ f
+(1 row)
+
+-- ok - alter subscription with conflict log table = NONE
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = NONE);
+-- should return NULL, meaning the table was dropped
+SELECT to_regclass('public.regress_conflict_log1');
+ to_regclass
+-------------
+
+(1 row)
+
+SELECT subname, subconflictlogtable FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogtable
+------------------------+---------------------
+ regress_conflict_test2 |
+(1 row)
+
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+-- fail - can not create conflict log table in pg_temp
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'pg_temp.regress_conflict_log1');
+ERROR: cannot create conflict log table "regress_conflict_log1" in a temporary namespace
+HINT: Use a permanent schema.
+-- fail - empty string is not allowed for conflict log table name
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = '');
+ERROR: conflict log table name cannot be empty
+HINT: Provide a valid table name or omit the parameter.
RESET SESSION AUTHORIZATION;
+DROP SCHEMA clt;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
DROP ROLE regress_subscription_user3;
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index ef0c298d2df..6b6f1503145 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -365,7 +365,121 @@ COMMIT;
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG TABLE TESTS
+--
+
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+
+-- fail - conflict_log_table specified when table already exists
+CREATE TABLE public.regress_conflict_log_temp (id int);
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log_temp');
+DROP TABLE public.regress_conflict_log_temp;
+
+-- ok - conflict_log_table creation with CREATE SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+
+-- check metadata in pg_subscription
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- check if the table exists and has the correct schema (11 columns)
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attnum > 0;
+
+-- check a specific column type (e.g., remote_tuple should be JSON)
+SELECT format_type(atttypid, atttypmod) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attname = 'remote_tuple';
+
+\dRs+
+
+-- ok - adding conflict_log_table with ALTER SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log2');
+
+-- check metadata after ALTER
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- ok - change the conflict log table name for an existing subscription that already had one
+CREATE SCHEMA clt;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'clt.regress_conflict_log3');
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+\dRs+
+
+-- check the new table was created and the old table was dropped
+SELECT count(*) FROM pg_class WHERE relname = 'regress_conflict_log2';
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'clt.regress_conflict_log3'::regclass AND attnum > 0;
+
+-- ok (NOTICE) - set conflict_log_table to one already used by this subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'clt.regress_conflict_log3');
+
+-- fail - try to publish the conflict_log_table
+CREATE PUBLICATION pub FOR TABLE clt.regress_conflict_log3;
+
+-- suppress warning that depends on wal_level
+SET client_min_messages = 'ERROR';
+
+-- ok - conflict_log_table should not be published with ALL TABLE
+CREATE PUBLICATION pub FOR TABLES IN SCHEMA clt;
+SELECT * FROM pg_publication_tables WHERE pubname = 'pub';
+\dt+ clt.regress_conflict_log3
+DROP PUBLICATION pub;
+
+-- fail - set conflict_log_table to one already used by a different subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log1');
+
+-- ok - dropping subscription also drops the log table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the table was dropped
+SELECT to_regclass('public.regress_conflict_log1');
+
+-- fail - dropping log table manually not allowed
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+DROP TABLE public.regress_conflict_log1;
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the subscription was dropped successfully
+SELECT subname FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- ok - create subscription with conflict_log_table = NONE
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = NONE);
+SELECT subname, subconflictlogtable FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+-- ok - alter subscription with valid conflict log table name
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log1');
+SELECT subname, subconflictlogtable FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- ok - pg_relation_is_publishable should return false for conflict log table
+SELECT pg_relation_is_publishable('public.regress_conflict_log1');
+
+-- ok - alter subscription with conflict log table = NONE
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = NONE);
+
+-- should return NULL, meaning the table was dropped
+SELECT to_regclass('public.regress_conflict_log1');
+SELECT subname, subconflictlogtable FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+
+-- fail - can not create conflict log table in pg_temp
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'pg_temp.regress_conflict_log1');
+
+-- fail - empty string is not allowed for conflict log table name
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = '');
+
RESET SESSION AUTHORIZATION;
+DROP SCHEMA clt;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
DROP ROLE regress_subscription_user3;
--
2.49.0
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-14 15:50 Dilip Kumar <dilipbalaut@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-12-14 15:50 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; Amit Kapila <amit.kapila16@gmail.com>
On Fri, Dec 12, 2025 at 3:33 PM shveta malik <shveta.malik@gmail.com> wrote:
>
>
> Few comments for v11:
>
> 1)
> +#include "executor/spi.h"
> +#include "replication/conflict.h"
> +#include "utils/fmgroids.h"
> +#include "utils/regproc.h"
>
> subscriptioncmds.c compiles without the above inclusions.
I think we need utils/regproc.h for "stringToQualifiedNameList()"
> 2)
> postgres=# create subscription sub3 connection '...' publication pub1
> WITH(conflict_log_table='pg_temp.clt');
> NOTICE: created replication slot "sub3" on publisher
> CREATE SUBSCRIPTION
>
> Should we restrict clt creation in pg_temp?
Done and added a test.
> 3)
> + /* Fetch the eixsting conflict table table information. */
>
> typos: eixsting->existing,
> table table -> table
Fixed
> 4)
> AlterSubscription():
> + values[Anum_pg_subscription_subconflictlognspid - 1] =
> + ObjectIdGetDatum(nspid);
> +
> + if (relname != NULL)
> + values[Anum_pg_subscription_subconflictlogtable - 1] =
> + CStringGetTextDatum(relname);
> + else
> + nulls[Anum_pg_subscription_subconflictlogtable - 1] =
> + true;
>
> Should we move the nspid setting inside 'if(relname != NULL)' block?
Since subconflictlognspid is part of the fixed size structure so we
will always have to set it so I prefer it to keep it out.
> 5)
> Is there a way to reset/remove conflict_log_table? I did not see any
> such handling in AlterSubscription? It gives error:
>
> postgres=# alter subscription sub3 set (conflict_log_table='');
> ERROR: invalid name syntax
Fixed and added a test case
> 6)
> +char *
> +get_subscription_conflict_log_table(Oid subid, Oid *nspid)
> +{
> + HeapTuple tup;
> + Datum datum;
> + bool isnull;
> + char *relname = NULL;
> + Form_pg_subscription subform;
> +
> + *nspid = InvalidOid;
> +
> + tup = SearchSysCache1(SUBSCRIPTIONOID, ObjectIdGetDatum(subid));
> +
> + if (!HeapTupleIsValid(tup))
> + return NULL;
>
> Should we have elog(ERROR) here for cache lookup failure? Callers like
> AlterSubscription, DropSubscription lock the sub entry, so it being
> missing at this stage is not normal. I have not seen all the callers
> though.
Yeah we can do that.
> 7)
> +#include "access/htup.h"
> +#include "access/skey.h"
>
> +#include "access/table.h"
> +#include "catalog/pg_attribute.h"
> +#include "catalog/indexing.h"
> +#include "catalog/namespace.h"
> +#include "catalog/pg_namespace.h"
> +#include "catalog/pg_type.h"
>
> +#include "executor/spi.h"
> +#include "utils/array.h"
>
> conflict.c compiles without above inclusions.
Done
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-15 08:45 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: shveta malik @ 2025-12-15 08:45 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>
On Sun, Dec 14, 2025 at 9:20 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
Thanks for the patch. Few comments:
1)
+ if (isTempNamespace(namespaceId))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot create conflict log table \"%s\" in a temporary namespace",
+ conflictrel),
+ errhint("Use a permanent schema.")));
a)
Shall we use 'temporary schema' instead of 'temporary namespace'? See
other similar errors:
errmsg("cannot move objects into or out of temporary schemas")
errmsg("cannot create relations in temporary schemas of other
sessions"))
errmsg("cannot create temporary relation in non-temporary schema")
b)
Do we really need errhint here? It seems self-explanatory. If we
really want to specify HINT, shall we say:
"Specify a non-temporary schema for conflict log table."
2)
postgres=# alter subscription sub1 set (conflict_log_table='');
ERROR: conflict log table name cannot be empty
HINT: Provide a valid table name or omit the parameter.
My idea was to allow the above operation to enable users to reset the
conflict_log_table when the conflict log history is no longer needed.
Is there any other way to reset it, or is this intentionally not
supported?
3)
postgres=# alter subscription sub1 set (conflict_log_table=NULL);
ALTER SUBSCRIPTION
postgres=# alter subscription sub2 set (conflict_log_table=create);
ALTER SUBSCRIPTION
postgres=# \d
List of relations
Schema | Name | Type | Owner
--------+---------+-------+--------
public | create | table | shveta
public | null | table | shveta
It takes reserved keywords and creates tables with those names. It
should be restricted.
4)
postgres=# SELECT c.relname FROM pg_depend d JOIN pg_class c ON c.oid
= d.objid JOIN pg_subscription s ON s.oid = d.refobjid WHERE s.subname
= 'sub1';
relname
---------
clt
postgres=# select count(*) from pg_shdepend where refobjid = (select
oid from pg_subscription where subname='sub1');
count
-------
0
Since dependency between sub and clt is a dependency involving
shared-object, shouldn't the entry be in pg_shdepend? Or do we allow
such entries in pg_depend as well?
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-15 09:25 Dilip Kumar <dilipbalaut@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 2 replies; 912+ messages in thread
From: Dilip Kumar @ 2025-12-15 09:25 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; Amit Kapila <amit.kapila16@gmail.com>
On Mon, Dec 15, 2025 at 2:16 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> On Sun, Dec 14, 2025 at 9:20 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
>
> Thanks for the patch. Few comments:
>
> 2)
> postgres=# alter subscription sub1 set (conflict_log_table='');
> ERROR: conflict log table name cannot be empty
> HINT: Provide a valid table name or omit the parameter.
>
> My idea was to allow the above operation to enable users to reset the
> conflict_log_table when the conflict log history is no longer needed.
> Is there any other way to reset it, or is this intentionally not
> supported?
ALTEr SUBSCRIPTION..SET (conflict_log_table=NONE); this is same as how
other subscription parameters are being reset
> 3)
> postgres=# alter subscription sub1 set (conflict_log_table=NULL);
> ALTER SUBSCRIPTION
> postgres=# alter subscription sub2 set (conflict_log_table=create);
> ALTER SUBSCRIPTION
> postgres=# \d
> List of relations
> Schema | Name | Type | Owner
> --------+---------+-------+--------
> public | create | table | shveta
> public | null | table | shveta
>
>
> It takes reserved keywords and creates tables with those names. It
> should be restricted.
I somehow assume table creation will be restricted with these names,
but since we switch from SPI to internal interface its not true
anymore, need to see how we can handle this.
> 4)
> postgres=# SELECT c.relname FROM pg_depend d JOIN pg_class c ON c.oid
> = d.objid JOIN pg_subscription s ON s.oid = d.refobjid WHERE s.subname
> = 'sub1';
> relname
> ---------
> clt
>
> postgres=# select count(*) from pg_shdepend where refobjid = (select
> oid from pg_subscription where subname='sub1');
> count
> -------
> 0
>
> Since dependency between sub and clt is a dependency involving
> shared-object, shouldn't the entry be in pg_shdepend? Or do we allow
> such entries in pg_depend as well?
The primary reason for recording in pg_depend is that the
RemoveRelations() function already includes logic to check for and
report internal dependencies within pg_depends. Consequently, if we
were to record the dependency in pg_shdepends, we would likely need to
modify RemoveRelations() to incorporate handling for pg_shdepends
dependencies.
However, some might argue that when an object ID (objid) is local and
the referenced object ID (refobjid) is shared, such as when a table is
created under a ROLE, establishing a dependency with the owner, the
dependency is currently recorded in pg_shdepend. In this scenario, the
dependent object (the local table) can be dropped independently, while
the referenced object (the shared owner) cannot. However, when aiming
to record an internal dependency, the dependent object should not be
droppable without first dropping the referencing object. Therefore, I
believe the dependency record should be placed in pg_depend, as the
depender is a local object and will check for dependencies there.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-15 09:48 Amit Kapila <amit.kapila16@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
2 siblings, 2 replies; 912+ messages in thread
From: Amit Kapila @ 2025-12-15 09:48 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Sun, Dec 14, 2025 at 9:16 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> Here is the patch which implements the dependency and fixes other
> comments from Shveta.
>
+/*
+ * Check if the specified relation is used as a conflict log table by any
+ * subscription.
+ */
+bool
+IsConflictLogTable(Oid relid)
+{
+ Relation rel;
+ TableScanDesc scan;
+ HeapTuple tup;
+ bool is_clt = false;
+
+ rel = table_open(SubscriptionRelationId, AccessShareLock);
+ scan = table_beginscan_catalog(rel, 0, NULL);
+
+ while (HeapTupleIsValid(tup = heap_getnext(scan, ForwardScanDirection)))
This function has been used at multiple places in the patch, though
not in any performance-critical paths, but still, it seems like the
impact can be noticeable for a large number of subscriptions. Also, I
am not sure it is a good design to scan the entire system table to
find whether some other relation is publishable or not. I see below
kinds of usages for it:
+ /* Subscription conflict log tables are not published */
+ result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple)) &&
+ !IsConflictLogTable(relid);
In this regard, I see a comment atop is_publishable_class which
suggests as follows:
The best
* long-term solution may be to add a "relispublishable" bool to pg_class,
* and depend on that instead of OID checks.
*/
static bool
is_publishable_class(Oid relid, Form_pg_class reltuple)
I feel that is a good idea for reasons mentioned atop
is_publishable_class and for the conflict table. What do you think?
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-15 10:31 Dilip Kumar <dilipbalaut@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-12-15 10:31 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Mon, Dec 15, 2025 at 3:18 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Sun, Dec 14, 2025 at 9:16 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > Here is the patch which implements the dependency and fixes other
> > comments from Shveta.
> >
>
> +/*
> + * Check if the specified relation is used as a conflict log table by any
> + * subscription.
> + */
> +bool
> +IsConflictLogTable(Oid relid)
> +{
> + Relation rel;
> + TableScanDesc scan;
> + HeapTuple tup;
> + bool is_clt = false;
> +
> + rel = table_open(SubscriptionRelationId, AccessShareLock);
> + scan = table_beginscan_catalog(rel, 0, NULL);
> +
> + while (HeapTupleIsValid(tup = heap_getnext(scan, ForwardScanDirection)))
>
> This function has been used at multiple places in the patch, though
> not in any performance-critical paths, but still, it seems like the
> impact can be noticeable for a large number of subscriptions. Also, I
> am not sure it is a good design to scan the entire system table to
> find whether some other relation is publishable or not. I see below
> kinds of usages for it:
>
> + /* Subscription conflict log tables are not published */
> + result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple)) &&
> + !IsConflictLogTable(relid);
>
> In this regard, I see a comment atop is_publishable_class which
> suggests as follows:
>
> The best
> * long-term solution may be to add a "relispublishable" bool to pg_class,
> * and depend on that instead of OID checks.
> */
> static bool
> is_publishable_class(Oid relid, Form_pg_class reltuple)
>
> I feel that is a good idea for reasons mentioned atop
> is_publishable_class and for the conflict table. What do you think?
On quick thought, this seems like a good idea and may simplify a
couple of places. And might be good for future extension as we can
mark publishable at individual relation instead of targeting broad
categories like IsCatalogRelationOid() or checking individual items by
its Oid. IMHO this can be done as an individual patch in a separate
thread, or as a base patch.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-15 11:15 Amit Kapila <amit.kapila16@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 0 replies; 912+ messages in thread
From: Amit Kapila @ 2025-12-15 11:15 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Mon, Dec 15, 2025 at 4:02 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Mon, Dec 15, 2025 at 3:18 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> >
> > On Sun, Dec 14, 2025 at 9:16 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > Here is the patch which implements the dependency and fixes other
> > > comments from Shveta.
> > >
> >
> > +/*
> > + * Check if the specified relation is used as a conflict log table by any
> > + * subscription.
> > + */
> > +bool
> > +IsConflictLogTable(Oid relid)
> > +{
> > + Relation rel;
> > + TableScanDesc scan;
> > + HeapTuple tup;
> > + bool is_clt = false;
> > +
> > + rel = table_open(SubscriptionRelationId, AccessShareLock);
> > + scan = table_beginscan_catalog(rel, 0, NULL);
> > +
> > + while (HeapTupleIsValid(tup = heap_getnext(scan, ForwardScanDirection)))
> >
> > This function has been used at multiple places in the patch, though
> > not in any performance-critical paths, but still, it seems like the
> > impact can be noticeable for a large number of subscriptions. Also, I
> > am not sure it is a good design to scan the entire system table to
> > find whether some other relation is publishable or not. I see below
> > kinds of usages for it:
> >
> > + /* Subscription conflict log tables are not published */
> > + result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple)) &&
> > + !IsConflictLogTable(relid);
> >
> > In this regard, I see a comment atop is_publishable_class which
> > suggests as follows:
> >
> > The best
> > * long-term solution may be to add a "relispublishable" bool to pg_class,
> > * and depend on that instead of OID checks.
> > */
> > static bool
> > is_publishable_class(Oid relid, Form_pg_class reltuple)
> >
> > I feel that is a good idea for reasons mentioned atop
> > is_publishable_class and for the conflict table. What do you think?
>
> On quick thought, this seems like a good idea and may simplify a
> couple of places. And might be good for future extension as we can
> mark publishable at individual relation instead of targeting broad
> categories like IsCatalogRelationOid() or checking individual items by
> its Oid. IMHO this can be done as an individual patch in a separate
> thread, or as a base patch.
>
I prefer to do it in a separate thread, so that it can get some more
attention. But it should be done before the main conflict patch. I
think we can subdivide the main patch into (a) DDL handling,
everything except inserting data into conflict table, (b) inserting
data into conflict table, (c) upgrade handling. That way it will be
easier to review.
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-15 11:41 Dilip Kumar <dilipbalaut@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-12-15 11:41 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; Amit Kapila <amit.kapila16@gmail.com>
On Mon, Dec 15, 2025 at 2:55 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> > 3)
> > postgres=# alter subscription sub1 set (conflict_log_table=NULL);
> > ALTER SUBSCRIPTION
> > postgres=# alter subscription sub2 set (conflict_log_table=create);
> > ALTER SUBSCRIPTION
> > postgres=# \d
> > List of relations
> > Schema | Name | Type | Owner
> > --------+---------+-------+--------
> > public | create | table | shveta
> > public | null | table | shveta
> >
> >
> > It takes reserved keywords and creates tables with those names. It
> > should be restricted.
>
> I somehow assume table creation will be restricted with these names,
> but since we switch from SPI to internal interface its not true
> anymore, need to see how we can handle this.
While thinking more on this, I was seeing other places where we use
'heap_create_with_catalog()' so I noticed that we always use the
internally generated name, so wouldn't it be nice to make the conflict
log table as bool and use internally generated name something like
conflict_log_table_$subid$ and we will always create that in current
active searchpath? Thought?
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-16 01:10 Peter Smith <smithpb2250@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
2 siblings, 0 replies; 912+ messages in thread
From: Peter Smith @ 2025-12-16 01:10 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Some review comments for v12-0001.
======
General
1.
There is no documentation. Even if it seems a bit premature IMO
writing/reviewing the documention could help identify unanticipated
usability issues.
======
src/backend/commands/subscriptioncmds.c
2.
+
+ /* Setting conflict_log_table = NONE is treated as no table. */
+ if (strcmp(opts->conflictlogtable, "none") == 0)
+ opts->conflictlogtable = NULL;
+ }
2a.
This was unexpected when I cam across this code. This feature needs to
be described in the commit message.
~
2b.
Case sensitive?
~~~
CreateSubscription:
3.
+ List *names;
+
+ /* Explicitly check for empty string before any processing. */
+ if (opts.conflictlogtable[0] == '\0')
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("conflict log table name cannot be empty"),
+ errhint("Provide a valid table name or omit the parameter.")));
+
+ names = stringToQualifiedNameList(opts.conflictlogtable, NULL);
Should '' just be equivalent of NONE instead of another error condition?
~~~
AlterSubscription:
4.
+ Oid old_nspid = InvalidOid;
+ char *old_relname = NULL;
+ char *relname = NULL;
+ List *names = NIL;
Var 'names' can be declared at a lower scope -- e.g. in the 'if' block.
~~~
DropSubscription:
5.
+ /*
+ * Conflict log tables are recorded as internal dependencies of the
+ * subscription. We must drop the dependent objects before the
+ * subscription itself is removed. By using
+ * PERFORM_DELETION_SKIP_ORIGINAL, we ensure that only the conflict log
+ * table is reaped while the subscription remains for the final deletion
+ * step.
+ */
Double spaces? /the subscription/the subscription/
~~~
create_conflict_log_table_tupdesc:
6.
+static TupleDesc
+create_conflict_log_table_tupdesc(void)
+{
+ TupleDesc tupdesc;
+ int i;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_CONFLICT_ATTR_NUM);
+
+ for (i = 0; i < MAX_CONFLICT_ATTR_NUM; i++)
Declare 'i' as a for-loop var.
~~~
create_conflict_log_table:
7.
+/*
+ * Create conflict log table.
+ *
+ * The subscription owner becomes the owner of this table and has all
+ * privileges on it.
+ */
+static void
+create_conflict_log_table(Oid namespaceId, char *conflictrel, Oid subid)
+{
I felt that the 'subid' should be the first parameter, not the last.
~~~
8.
namespace > relation, so I felt it is more natural to check for the
temp namespace *before* checking for clashing table names.
======
src/backend/replication/logical/conflict.c
9.
+ if (ValidateConflictLogTable(conflictlogrel))
+ {
+ /*
+ * Prepare the conflict log tuple. If the error level is below
+ * ERROR, insert it immediately. Otherwise, defer the insertion to
+ * a new transaction after the current one aborts, ensuring the
+ * insertion of the log tuple is not rolled back.
+ */
+ prepare_conflict_log_tuple(estate,
+ relinfo->ri_RelationDesc,
+ conflictlogrel,
+ type,
+ searchslot,
+ conflicttuples,
+ remoteslot);
+ if (elevel < ERROR)
+ InsertConflictLogTuple(conflictlogrel);
+ }
+ else
+ ereport(WARNING,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("Conflict log table \"%s.%s\" structure changed, skipping insertion",
+ get_namespace_name(RelationGetNamespace(conflictlogrel)),
+ RelationGetRelationName(conflictlogrel)));
9a.
AFAICT in the only few places this function is called it emits exactly
the same warning, so it seems unnecessary duplication. Would it be
better to have that WARNING code inside the ValidateConflictLogTable
(eg always give the warning when returning false). But see also 9b.
~
9b.
I have some doubts about this validation function. It seems
inefficient to be validating the same CLT structure over and over
every time there is a new conflict. Not only is that going to be
slower, but the logfile is going to fill up with warnings. Maybe this
"validation" phase should be a one-time check only during the
CREATE/ALTER SUBSCRIPTION.
Maybe if validation fails it could give some NOTICE that the CLT
logging is broken and then reset the CLT to NONE?
~~~
ValidateConflictLogTable:
10.
+/*
+ * ValidateConflictLogTable - Validate conflict log table
+ *
+ * Validate whether the conflict log table is still suitable for considering as
+ * conflict log table.
+ */
+bool
+ValidateConflictLogTable(Relation rel)
This function comment seems unhelpful. 3 times it mentions equivalent
of "validate conflict log table" but nowhere does it say what that
even means.
Maybe the later comment (below):
+ /*
+ * Check whether the table definition including its column names, data
+ * types, and column ordering meets the requirements for conflict log
+ * table.
+ */
Should be moved into the function comment part.
~~~
11.
+ Relation pg_attribute;
+ HeapTuple atup;
+ ScanKeyData scankey;
+ SysScanDesc scan;
+ Form_pg_attribute attForm;
+ int attcnt = 0;
+ bool tbl_ok = true;
'attForm' can be declared within the while loop.
~~~
12.
+ if (attcnt != MAX_CONFLICT_ATTR_NUM || !tbl_ok)
+ return false;
As per previous review comment, this could emit the WARNING log right
here. But see also #9b.
~~~
build_local_conflicts_json_array:
13.
+ Datum values[MAX_LOCAL_CONFLICT_INFO_ATTRS];
+ bool nulls[MAX_LOCAL_CONFLICT_INFO_ATTRS];
+ char *origin_name = NULL;
+ HeapTuple tuple;
+ Datum json_datum;
+ int attno;
+
+ memset(values, 0, sizeof(Datum) * MAX_LOCAL_CONFLICT_INFO_ATTRS);
+ memset(nulls, 0, sizeof(bool) * MAX_LOCAL_CONFLICT_INFO_ATTRS);
You could also just use designated initializer syntax here and avoid
the memsets.
e.g. = {0}
~~~
14.
+ memset(values, 0, sizeof(Datum) * MAX_LOCAL_CONFLICT_INFO_ATTRS);
+ memset(nulls, 0, sizeof(bool) * MAX_LOCAL_CONFLICT_INFO_ATTRS);
Another place where you could've avoided memset and just done = {0};
~~~
15.
+ json_datum_array = (Datum *) palloc(num_conflicts * sizeof(Datum));
+ json_null_array = (bool *) palloc0(num_conflicts * sizeof(bool));
- index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+ i = 0;
+ foreach(lc, json_datums)
+ {
+ json_datum_array[i] = (Datum) lfirst(lc);
+ i++;
+ }
Should these be using new palloc_array instead of palloc?
======
src/include/replication/conflict.h
16.
+typedef struct ConflictLogColumnDef
+{
+ const char *attname; /* Column name */
+ Oid atttypid; /* Data type OID */
+} ConflictLogColumnDef;
Add this to typedefs.list
~~~
17.
+/* The single source of truth for the conflict log table schema */
+static const ConflictLogColumnDef ConflictLogSchema[] =
+{
+ { .attname = "relid", .atttypid = OIDOID },
+ { .attname = "schemaname", .atttypid = TEXTOID },
+ { .attname = "relname", .atttypid = TEXTOID },
+ { .attname = "conflict_type", .atttypid = TEXTOID },
+ { .attname = "remote_xid", .atttypid = XIDOID },
+ { .attname = "remote_commit_lsn",.atttypid = LSNOID },
+ { .attname = "remote_commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "remote_origin", .atttypid = TEXTOID },
+ { .attname = "replica_identity", .atttypid = JSONOID },
+ { .attname = "remote_tuple", .atttypid = JSONOID },
+ { .attname = "local_conflicts", .atttypid = JSONARRAYOID }
+};
I like this, but I felt it would be better if all the definitions for
"local_conflicts" were defined here too. Then everythin gis in one
place.
e.g. MAX_LOCAL_CONFLICT_INFO_ATTRS and most of the content of
build_conflict_tupledesc().
~~~
18.
+/* Define the count using the array size */
+#define MAX_CONFLICT_ATTR_NUM (sizeof(ConflictLogSchema) /
sizeof(ConflictLogSchema[0]))
This comment is just saying same as the code so doesn't seem to be useful.
======
src/test/regress/expected/subscription.out
19.
+\dt+ clt.regress_conflict_log3
+ List of tables
+ Schema | Name | Type | Owner |
Persistence | Size | Description
+--------+-----------------------+-------+---------------------------+-------------+---------+-------------
+ clt | regress_conflict_log3 | table | regress_subscription_user |
permanent | 0 bytes |
+(1 row)
Since the CLT is auto-created internally, and since there is a
"Description" attribute, I wonder should you also be auto-generating
that description so that here it might say something useful like:
"Conflict Log File for subscription XYZ"
~~~
20.
+-- ok - create subscription with conflict_log_table = NONE
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION
'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect =
false, conflict_log_table = NONE);
+SELECT subname, subconflictlogtable FROM pg_subscription WHERE
subname = 'regress_conflict_test2';
+ subname | subconflictlogtable
+------------------------+-----------------------
+ regress_conflict_test2 | regress_conflict_log3
+(1 row)
+
I didn't understand this test case; You are setting a NONE clt for
subscription 'regress_conflict_test1'. But then you are checking
subname 'regress_conflict_test2'.
Is that a typo?
~~~
21.
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
Something seems misplaced. Why aren't all of the cleanups under the
'cleanup' comment?
======
Kind Regards,
Peter Smith.
Fujitsu Australia
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-16 04:21 Amit Kapila <amit.kapila16@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Amit Kapila @ 2025-12-16 04:21 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Mon, Dec 15, 2025 at 5:11 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Mon, Dec 15, 2025 at 2:55 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > > 3)
> > > postgres=# alter subscription sub1 set (conflict_log_table=NULL);
> > > ALTER SUBSCRIPTION
> > > postgres=# alter subscription sub2 set (conflict_log_table=create);
> > > ALTER SUBSCRIPTION
> > > postgres=# \d
> > > List of relations
> > > Schema | Name | Type | Owner
> > > --------+---------+-------+--------
> > > public | create | table | shveta
> > > public | null | table | shveta
> > >
> > >
> > > It takes reserved keywords and creates tables with those names. It
> > > should be restricted.
> >
> > I somehow assume table creation will be restricted with these names,
> > but since we switch from SPI to internal interface its not true
> > anymore, need to see how we can handle this.
>
> While thinking more on this, I was seeing other places where we use
> 'heap_create_with_catalog()' so I noticed that we always use the
> internally generated name, so wouldn't it be nice to make the conflict
> log table as bool and use internally generated name something like
> conflict_log_table_$subid$ and we will always create that in current
> active searchpath? Thought?
>
We could do this as a first step. See the proposal in email [1] where
we have discussed having two options instead of one. The first option
will be conflict_log_format and the values would be log and table. In
this case, the table would be an internally generated one.
[1] - https://www.postgresql.org/message-id/CAA4eK1KwqE2y%3D_k5Xc%3Def0S5JXG2x%3DoeWpDJ%2B%3D5k6Anzaw2gdw%...
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-16 04:24 vignesh C <vignesh21@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
2 siblings, 1 reply; 912+ messages in thread
From: vignesh C @ 2025-12-16 04:24 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Sun, 14 Dec 2025 at 21:17, Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Sun, Dec 14, 2025 at 3:51 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Fri, Dec 12, 2025 at 3:04 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > >
> > > On Thu, Dec 11, 2025 at 7:49 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > >
> > > > I was considering the interdependence between the subscription and the
> > > > conflict log table (CLT). IMHO, it would be logical to establish the
> > > > subscription as dependent on the CLT. This way, if someone attempts to
> > > > drop the CLT, the system would recognize the dependency of the
> > > > subscription and prevent the drop unless the subscription is removed
> > > > first or the CASCADE option is used.
> > > >
> > > > However, while investigating this, I encountered an error [1] stating
> > > > that global objects are not supported in this context. This indicates
> > > > that global objects cannot be made dependent on local objects.
> > > >
> > >
> > > What we need here is an equivalent of DEPENDENCY_INTERNAL for database
> > > objects. For example, consider following case:
> > > postgres=# create table t1(c1 int primary key);
> > > CREATE TABLE
> > > postgres=# \d+ t1
> > > Table "public.t1"
> > > Column | Type | Collation | Nullable | Default | Storage |
> > > Compression | Stats target | Description
> > > --------+---------+-----------+----------+---------+---------+-------------+--------------+-------------
> > > c1 | integer | | not null | | plain |
> > > | |
> > > Indexes:
> > > "t1_pkey" PRIMARY KEY, btree (c1)
> > > Publications:
> > > "pub1"
> > > Not-null constraints:
> > > "t1_c1_not_null" NOT NULL "c1"
> > > Access method: heap
> > > postgres=# drop index t1_pkey;
> > > ERROR: cannot drop index t1_pkey because constraint t1_pkey on table
> > > t1 requires it
> > > HINT: You can drop constraint t1_pkey on table t1 instead.
> > >
> > > Here, the PK index is created as part for CREATE TABLE operation and
> > > pk_index is not allowed to be dropped independently.
> > >
> > > > Although making an object dependent on global/shared objects is
> > > > possible for certain types of shared objects [2], this is not our main
> > > > objective.
> > > >
> > >
> > > As per my understanding from the above example, we need something like
> > > that only for shared object subscription and (internally created)
> > > table.
> >
> > Yeah that seems to be exactly what we want, so I tried doing that by
> > recording DEPENDENCY_INTERNAL dependency of CLT on subscription[1] and
> > it is behaving as we want[2]. And while dropping the subscription or
> > altering CLT we can delete internal dependency so that CLT get dropped
> > automatically[3]
> >
> > I will send an updated patch after testing a few more scenarios and
> > fixing other pending issues.
> >
> > [1]
> > + ObjectAddressSet(myself, RelationRelationId, relid);
> > + ObjectAddressSet(subaddr, SubscriptionRelationId, subid);
> > + recordDependencyOn(&myself, &subaddr, DEPENDENCY_INTERNAL);
> >
> >
> > [2]
> > postgres[670778]=# DROP TABLE myschema.conflict_log_history2;
> > ERROR: 2BP01: cannot drop table myschema.conflict_log_history2
> > because subscription sub requires it
> > HINT: You can drop subscription sub instead.
> > LOCATION: findDependentObjects, dependency.c:788
> > postgres[670778]=#
> >
> > [3]
> > ObjectAddressSet(object, SubscriptionRelationId, subid);
> > performDeletion(&object, DROP_CASCADE
> > PERFORM_DELETION_INTERNAL |
> > PERFORM_DELETION_SKIP_ORIGINAL);
> >
> >
>
> Here is the patch which implements the dependency and fixes other
> comments from Shveta.
Thanks for the changes, the new implementation based on dependency
creates a cycle while dumping:
./pg_dump -d postgres -f dump1.txt -p 5433
pg_dump: warning: could not resolve dependency loop among these items:
pg_dump: detail: TABLE conflict (ID 225 OID 16397)
pg_dump: detail: SUBSCRIPTION (ID 3484 OID 16396)
pg_dump: detail: POST-DATA BOUNDARY (ID 3491)
pg_dump: detail: TABLE DATA t1 (ID 3485 OID 16384)
pg_dump: detail: PRE-DATA BOUNDARY (ID 3490)
This can be seen with a simple subscription with conflict_log_table.
This was working fine with the v11 version patch.
Regards,
Vignesh
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-16 05:03 shveta malik <shveta.malik@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: shveta malik @ 2025-12-16 05:03 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: Dilip Kumar <dilipbalaut@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Mon, Dec 15, 2025 at 3:18 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Sun, Dec 14, 2025 at 9:16 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > Here is the patch which implements the dependency and fixes other
> > comments from Shveta.
> >
>
> +/*
> + * Check if the specified relation is used as a conflict log table by any
> + * subscription.
> + */
> +bool
> +IsConflictLogTable(Oid relid)
> +{
> + Relation rel;
> + TableScanDesc scan;
> + HeapTuple tup;
> + bool is_clt = false;
> +
> + rel = table_open(SubscriptionRelationId, AccessShareLock);
> + scan = table_beginscan_catalog(rel, 0, NULL);
> +
> + while (HeapTupleIsValid(tup = heap_getnext(scan, ForwardScanDirection)))
>
> This function has been used at multiple places in the patch, though
> not in any performance-critical paths, but still, it seems like the
> impact can be noticeable for a large number of subscriptions. Also, I
> am not sure it is a good design to scan the entire system table to
> find whether some other relation is publishable or not. I see below
> kinds of usages for it:
>
> + /* Subscription conflict log tables are not published */
> + result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple)) &&
> + !IsConflictLogTable(relid);
>
> In this regard, I see a comment atop is_publishable_class which
> suggests as follows:
>
> The best
> * long-term solution may be to add a "relispublishable" bool to pg_class,
> * and depend on that instead of OID checks.
> */
> static bool
> is_publishable_class(Oid relid, Form_pg_class reltuple)
>
> I feel that is a good idea for reasons mentioned atop
> is_publishable_class and for the conflict table. What do you think?
>
+1.
The OID check may be unreliable, as mentioned in the comment. I tested
this by dropping and recreating information_schema, and observed that
after recreation it became eligible for publication because its relid
no longer falls under FirstNormalObjectId. Steps:
****Pub****:
create publication pub1;
ALTER PUBLICATION pub1 ADD TABLE information_schema.sql_sizing;
select * from information_schema.sql_sizing where sizing_id=97;
****Sub****:
create subscription sub1 connection '...' publication pub1 with
(copy_data=false);
select * from information_schema.sql_sizing where sizing_id=97;
****Pub****:
alter table information_schema.sql_sizing replica identity full;
--this is not replicated.
UPDATE information_schema.sql_sizing set supported_value=12 where sizing_id=97;
****Sub****:
postgres=# select supported_value from information_schema.sql_sizing
where sizing_id=97;
supported_value
-----------------
0
~~
Then drop and recreate and try to perform the above update again, it
gets replicated:
drop schema information_schema cascade;
./psql -d postgres -f ./../../src/backend/catalog/information_schema.sql -p 5433
****Pub****:
ALTER PUBLICATION pub1 ADD TABLE information_schema.sql_sizing;
select * from information_schema.sql_sizing where sizing_id=97;
alter table information_schema.sql_sizing replica identity full;
--This is replicated
UPDATE information_schema.sql_sizing set supported_value=14 where sizing_id=97;
****Sub****:
--This shows supported_value as 14
postgres=# select supported_value from information_schema.sql_sizing
where sizing_id=97;
supported_value
-----------------
14
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-16 05:41 vignesh C <vignesh21@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
3 siblings, 0 replies; 912+ messages in thread
From: vignesh C @ 2025-12-16 05:41 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, 11 Dec 2025 at 19:50, Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Thu, Dec 11, 2025 at 5:57 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> >
> > On Thu, Dec 11, 2025 at 5:10 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > On Thu, Dec 11, 2025 at 5:04 PM shveta malik <shveta.malik@gmail.com> wrote:
> > >
> > > > 2)
> > > > When we do below:
> > > > alter subscription sub1 SET (conflict_log_table=clt2);
> > > >
> > > > the previous conflict log table is dropped. Is this behavior
> > > > intentional and discussed/concluded earlier? It’s possible that a user
> > > > may want to create a new conflict log table for future events while
> > > > still retaining the old one for analysis. If the subscription itself
> > > > is dropped, then dropping the CLT makes sense, but I’m not sure this
> > > > behavior is intended for ALTER SUBSCRIPTION. I do understand that
> > > > once we unlink CLT from subscription, later even DROP subscription
> > > > cannot drop it, but user can always drop it when not needed.
> > > >
> > > > If we plan to keep existing behavior, it should be clearly documented
> > > > in a CAUTION section, and the command should explicitly log the table
> > > > drop.
> > >
> > > Yeah we discussed this behavior and the conclusion was we would
> > > document this behavior and its user's responsibility to take necessary
> > > backup of the conflict log table data if they are setting a new log
> > > table or NONE for the subscription.
> > >
> >
> > +1. If we don't do this then it will be difficult to track for
> > postgres or users the previous conflict history tables.
>
> Right, it makes sense.
>
> Attached patch fixed most of the open comments
> 1) \dRs+ now show the schema qualified name
> 2) Now key_tuple and replica_identify tuple both are add in conflict
> log tuple wherever applicable
> 3) Refactored the code so that we can define the conflict log table
> schema only once in the header file and both create_conflict_log_table
> and ValidateConflictLogTable use it.
>
> I was considering the interdependence between the subscription and the
> conflict log table (CLT). IMHO, it would be logical to establish the
> subscription as dependent on the CLT. This way, if someone attempts to
> drop the CLT, the system would recognize the dependency of the
> subscription and prevent the drop unless the subscription is removed
> first or the CASCADE option is used.
>
> However, while investigating this, I encountered an error [1] stating
> that global objects are not supported in this context. This indicates
> that global objects cannot be made dependent on local objects.
> Although making an object dependent on global/shared objects is
> possible for certain types of shared objects [2], this is not our main
> objective.
>
> We do not need to make the CLT dependent on the subscription because
> the table can be dropped when the subscription is dropped anyway and
> we are already doing it as part of drop subscription as well as alter
> subscription when CLT is set to NONE or a different table. Therefore,
> extending the functionality of shared dependency is unnecessary for
> this purpose.
>
> Thoughts?
>
> [1]
> doDeletion()
> {
> ....
> /*
> * These global object types are not supported here.
> */
> case AuthIdRelationId:
> case DatabaseRelationId:
> case TableSpaceRelationId:
> case SubscriptionRelationId:
> case ParameterAclRelationId:
> elog(ERROR, "global objects cannot be deleted by doDeletion");
> break;
> }
>
> [2]
> typedef enum SharedDependencyType
> {
> SHARED_DEPENDENCY_OWNER = 'o',
> SHARED_DEPENDENCY_ACL = 'a',
> SHARED_DEPENDENCY_INITACL = 'i',
> SHARED_DEPENDENCY_POLICY = 'r',
> SHARED_DEPENDENCY_TABLESPACE = 't',
> SHARED_DEPENDENCY_INVALID = 0,
> } SharedDependencyType;
>
> Pending Items are:
> 1. Handling dump/upgrade
The attached patch has the changes for handling dump. This works on
top of v11 version, it does not work on v12 because of the issue
reported at [1]. Currently the upgrade does not work because of the
existing issue which is being tracked at [2], upgrade works with the
patch attached at [2].
[1] - https://www.postgresql.org/message-id/CALDaNm1zEYoSdf2Ns-%3DUJRw95E5sbfpB0oaNUWtRJN27Q1Knhw%40mail.g...
[2] - https://www.postgresql.org/message-id/CALDaNm2x3rd7C0_HjUpJFbxpAqXgm%3DQtoKfkEWDVA8h%2BJFpa_w%40mail...
Regards,
Vignesh
Attachments:
[application/octet-stream] 0001-topup_pg_dump-dump-conflict-log-table-configuration-for-su.patch (8.5K, ../../CALDaNm2xtXA44_i1-JTGF2wiWvEnWWAU7d2NqdvXserDnGZnxg@mail.gmail.com/2-0001-topup_pg_dump-dump-conflict-log-table-configuration-for-su.patch)
download | inline diff:
From 3e734ca8eab6083f6a27ea560e2fefd834f8ee73 Mon Sep 17 00:00:00 2001
From: Vignesh C <vignesh21@gmail.com>
Date: Tue, 16 Dec 2025 09:16:26 +0530
Subject: [PATCH] pg_dump: dump conflict log table configuration for
subscriptions
Allow pg_dump to preserve the conflict_log_table setting of logical
replication subscriptions.
---
src/backend/commands/subscriptioncmds.c | 33 +++++++++++++-
src/bin/pg_dump/pg_dump.c | 52 +++++++++++++++++++++-
src/bin/pg_dump/pg_dump.h | 1 +
src/bin/pg_dump/t/002_pg_dump.pl | 5 ++-
src/test/regress/expected/subscription.out | 5 ++-
5 files changed, 89 insertions(+), 7 deletions(-)
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index eb3fe068ddb..e30078c351c 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -1765,7 +1765,38 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
* provided.
*/
if (relname != NULL)
- create_conflict_log_table(nspid, relname, subid);
+ {
+ Oid conflictlogrelid = get_relname_relid(relname, nspid);
+ if (OidIsValid(conflictlogrelid))
+ {
+ Relation conflictlogrel;
+
+ conflictlogrel = table_open(conflictlogrelid,
+ RowExclusiveLock);
+ if (IsConflictLogTable(conflictlogrelid))
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("conflict log table \"%s.%s\" cannot be used",
+ get_namespace_name(RelationGetNamespace(conflictlogrel)),
+ RelationGetRelationName(conflictlogrel)),
+ errdetail("The specified table is already registered for a different subscription."),
+ errhint("Specify a different conflict log table."));
+
+ if (!ValidateConflictLogTable(conflictlogrel))
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("conflict log table \"%s.%s\" has an incompatible definition",
+ get_namespace_name(RelationGetNamespace(conflictlogrel)),
+ RelationGetRelationName(conflictlogrel)),
+ errdetail("The table does not match the required conflict log table structure."),
+ errhint("Create the conflict log table with the expected definition or specify a different table."));
+
+ table_close(conflictlogrel, NoLock);
+
+ }
+ else
+ create_conflict_log_table(nspid, relname, subid);
+ }
values[Anum_pg_subscription_subconflictlognspid - 1] =
ObjectIdGetDatum(nspid);
diff --git a/src/bin/pg_dump/pg_dump.c b/src/bin/pg_dump/pg_dump.c
index 24ad201af2f..1e286e531b2 100644
--- a/src/bin/pg_dump/pg_dump.c
+++ b/src/bin/pg_dump/pg_dump.c
@@ -5130,6 +5130,7 @@ getSubscriptions(Archive *fout)
int i_subfailover;
int i_subretaindeadtuples;
int i_submaxretention;
+ int i_subconflictlogrelid;
int i,
ntups;
@@ -5216,10 +5217,17 @@ getSubscriptions(Archive *fout)
if (fout->remoteVersion >= 190000)
appendPQExpBufferStr(query,
- " s.submaxretention\n");
+ " s.submaxretention,\n");
else
appendPQExpBuffer(query,
- " 0 AS submaxretention\n");
+ " 0 AS submaxretention,\n");
+
+ if (fout->remoteVersion >= 190000)
+ appendPQExpBufferStr(query,
+ " c.oid AS subconflictlogrelid\n");
+ else
+ appendPQExpBufferStr(query,
+ " 0::oid AS subconflictlogrelid\n");
appendPQExpBufferStr(query,
"FROM pg_subscription s\n");
@@ -5229,6 +5237,12 @@ getSubscriptions(Archive *fout)
"LEFT JOIN pg_catalog.pg_replication_origin_status o \n"
" ON o.external_id = 'pg_' || s.oid::text \n");
+ if (fout->remoteVersion >= 190000)
+ appendPQExpBufferStr(query,
+ "LEFT JOIN pg_class c ON c.relname = s.subconflictlogtable\n"
+ "LEFT JOIN pg_namespace n \n"
+ " ON n.oid = c.relnamespace AND n.oid = s.subconflictlognspid\n");
+
appendPQExpBufferStr(query,
"WHERE s.subdbid = (SELECT oid FROM pg_database\n"
" WHERE datname = current_database())");
@@ -5255,6 +5269,7 @@ getSubscriptions(Archive *fout)
i_subfailover = PQfnumber(res, "subfailover");
i_subretaindeadtuples = PQfnumber(res, "subretaindeadtuples");
i_submaxretention = PQfnumber(res, "submaxretention");
+ i_subconflictlogrelid = PQfnumber(res, "subconflictlogrelid");
i_subconninfo = PQfnumber(res, "subconninfo");
i_subslotname = PQfnumber(res, "subslotname");
i_subsynccommit = PQfnumber(res, "subsynccommit");
@@ -5292,6 +5307,22 @@ getSubscriptions(Archive *fout)
(strcmp(PQgetvalue(res, i, i_subretaindeadtuples), "t") == 0);
subinfo[i].submaxretention =
atoi(PQgetvalue(res, i, i_submaxretention));
+ subinfo[i].subconflictlogrelid =
+ atooid(PQgetvalue(res, i, i_subconflictlogrelid));
+
+ if (subinfo[i].subconflictlogrelid != InvalidOid)
+ {
+ TableInfo *tableInfo = findTableByOid(subinfo[i].subconflictlogrelid);
+
+ if (!tableInfo)
+ pg_fatal("could not find conflict log table with OID %u",
+ subinfo[i].subconflictlogrelid);
+
+ /* Ensure the table is marked to be dumped */
+ tableInfo->dobj.dump |= DUMP_COMPONENT_DEFINITION;
+
+ addObjectDependency(&subinfo[i].dobj, tableInfo->dobj.dumpId);
+ }
subinfo[i].subconninfo =
pg_strdup(PQgetvalue(res, i, i_subconninfo));
if (PQgetisnull(res, i, i_subslotname))
@@ -5564,6 +5595,23 @@ dumpSubscription(Archive *fout, const SubscriptionInfo *subinfo)
appendPQExpBufferStr(query, ");\n");
+ if (subinfo->subconflictlogrelid != InvalidOid)
+ {
+ PQExpBuffer conflictlogbuf = createPQExpBuffer();
+ TableInfo *tbinfo = findTableByOid(subinfo->subconflictlogrelid);
+
+ appendStringLiteralAH(conflictlogbuf,
+ fmtQualifiedDumpable(tbinfo),
+ fout);
+
+ appendPQExpBuffer(query,
+ "\n\nALTER SUBSCRIPTION %s SET (conflict_log_table = %s);\n",
+ qsubname,
+ conflictlogbuf->data);
+
+ destroyPQExpBuffer(conflictlogbuf);
+ }
+
/*
* In binary-upgrade mode, we allow the replication to continue after the
* upgrade.
diff --git a/src/bin/pg_dump/pg_dump.h b/src/bin/pg_dump/pg_dump.h
index 72a00e1bc20..20ffae491eb 100644
--- a/src/bin/pg_dump/pg_dump.h
+++ b/src/bin/pg_dump/pg_dump.h
@@ -719,6 +719,7 @@ typedef struct _SubscriptionInfo
bool subfailover;
bool subretaindeadtuples;
int submaxretention;
+ Oid subconflictlogrelid;
char *subconninfo;
char *subslotname;
char *subsynccommit;
diff --git a/src/bin/pg_dump/t/002_pg_dump.pl b/src/bin/pg_dump/t/002_pg_dump.pl
index e33aa95f6ff..ef11db6b8ee 100644
--- a/src/bin/pg_dump/t/002_pg_dump.pl
+++ b/src/bin/pg_dump/t/002_pg_dump.pl
@@ -3204,9 +3204,10 @@ my %tests = (
create_order => 50,
create_sql => 'CREATE SUBSCRIPTION sub3
CONNECTION \'dbname=doesnotexist\' PUBLICATION pub1
- WITH (connect = false, origin = any, streaming = on);',
+ WITH (connect = false, origin = any, streaming = on, conflict_log_table = \'conflict\');',
regexp => qr/^
- \QCREATE SUBSCRIPTION sub3 CONNECTION 'dbname=doesnotexist' PUBLICATION pub1 WITH (connect = false, slot_name = 'sub3', streaming = on);\E
+ \QCREATE SUBSCRIPTION sub3 CONNECTION 'dbname=doesnotexist' PUBLICATION pub1 WITH (connect = false, slot_name = 'sub3', streaming = on);\E\n\n\n
+ \QALTER SUBSCRIPTION sub3 SET (conflict_log_table = 'public.conflict');\E
/xm,
like => { %full_runs, section_post_data => 1, },
unlike => {
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index bab2d0ea954..64ee5b9d43e 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -630,8 +630,9 @@ SELECT * FROM pg_publication_tables WHERE pubname = 'pub';
DROP PUBLICATION pub;
-- fail - set conflict_log_table to one already used by a different subscription
ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log1');
-ERROR: cannot create conflict log table "public.regress_conflict_log1" because a table with that name already exists
-HINT: Use a different name for the conflict log table or drop the existing table.
+ERROR: conflict log table "public.regress_conflict_log1" cannot be used
+DETAIL: The specified table is already registered for a different subscription.
+HINT: Specify a different conflict log table.
-- ok - dropping subscription also drops the log table
ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
--
2.43.0
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-16 07:17 Amit Kapila <amit.kapila16@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
1 sibling, 0 replies; 912+ messages in thread
From: Amit Kapila @ 2025-12-16 07:17 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Mon, Dec 15, 2025 at 2:55 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Mon, Dec 15, 2025 at 2:16 PM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > On Sun, Dec 14, 2025 at 9:20 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> > 4)
> > postgres=# SELECT c.relname FROM pg_depend d JOIN pg_class c ON c.oid
> > = d.objid JOIN pg_subscription s ON s.oid = d.refobjid WHERE s.subname
> > = 'sub1';
> > relname
> > ---------
> > clt
> >
> > postgres=# select count(*) from pg_shdepend where refobjid = (select
> > oid from pg_subscription where subname='sub1');
> > count
> > -------
> > 0
> >
> > Since dependency between sub and clt is a dependency involving
> > shared-object, shouldn't the entry be in pg_shdepend? Or do we allow
> > such entries in pg_depend as well?
>
> The primary reason for recording in pg_depend is that the
> RemoveRelations() function already includes logic to check for and
> report internal dependencies within pg_depends. Consequently, if we
> were to record the dependency in pg_shdepends, we would likely need to
> modify RemoveRelations() to incorporate handling for pg_shdepends
> dependencies.
>
> However, some might argue that when an object ID (objid) is local and
> the referenced object ID (refobjid) is shared, such as when a table is
> created under a ROLE, establishing a dependency with the owner, the
> dependency is currently recorded in pg_shdepend. In this scenario, the
> dependent object (the local table) can be dropped independently, while
> the referenced object (the shared owner) cannot.
>
Yes and same is true for tablespaces. Consider below case:
create tablespace tbs location <tbs_location>;
create table t2(c1 int, c2 int) PARTITION BY RANGE(c1) tablespace tbs;
>
> However, when aiming
> to record an internal dependency, the dependent object should not be
> droppable without first dropping the referencing object. Therefore, I
> believe the dependency record should be placed in pg_depend, as the
> depender is a local object and will check for dependencies there.
>
I think it make sense to add the dependency entry in pg_depend for
this case (dependent object table is db-local and referenced object
subscription is shared among cluster) as there is a fundamental
architectural difference between Tablespaces/Roles and Subscriptions
that determines why one needs pg_shdepend and the other is better off
with pg_depend.
It comes down to cross-database visibility during the DROP command.
1. The "Tablespace" Scenario (Why it needs pg_shdepend)
A Tablespace is a truly global resource. You can connect to postgres
(database A) and try to drop a tablespace that is being used by app_db
(database B).
The Problem: When you run DROP TABLESPACE tbs from Database A, the
system cannot look inside Database B's pg_depend to see if the
tablespace is in use. It would have to connect to every database in
the cluster to check.
The Solution: We explicitly push this dependency up to the global
pg_shdepend. This allows the DROP command in Database A to instantly
see: "Wait, object 123 in Database B needs this. Block the drop."
2. The "Subscription" Scenario (Why it does NOT need pg_shdepend)
Although pg_subscription is a shared catalog, a Subscription is pinned
to a specific database (subdbid). One can only DROP SUBSCRIPTION while
connected to the database that owns it. Consider a scenario where one
creates a subscription sub_1 in app_db. Now, one cannot connect to
postgres DB and run DROP SUBSCRIPTION sub_1. She must connect to
app_db. Since we need to conenct to app_db to drop the subscription,
the system has direct, fast access to the local pg_depend of app_db.
It doesn't need to consult a global "Cross-DB" catalog because there
is no mystery about where the dependencies live.
Does this theory sound more bullet-proof as to why it is desirable to
store dependency entries for this case in pg_depend. If so, I suggest
we can add some comments to explain the difference of subscription
with other shared objects in comments as the future readers may have
the same question.
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-17 04:28 Dilip Kumar <dilipbalaut@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-12-17 04:28 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, Dec 16, 2025 at 10:33 AM shveta malik <shveta.malik@gmail.com> wrote:
> The OID check may be unreliable, as mentioned in the comment. I tested
> this by dropping and recreating information_schema, and observed that
> after recreation it became eligible for publication because its relid
> no longer falls under FirstNormalObjectId. Steps:
>
> ****Pub****:
> create publication pub1;
> ALTER PUBLICATION pub1 ADD TABLE information_schema.sql_sizing;
> select * from information_schema.sql_sizing where sizing_id=97;
>
> ****Sub****:
> create subscription sub1 connection '...' publication pub1 with
> (copy_data=false);
> select * from information_schema.sql_sizing where sizing_id=97;
>
> ****Pub****:
> alter table information_schema.sql_sizing replica identity full;
> --this is not replicated.
> UPDATE information_schema.sql_sizing set supported_value=12 where sizing_id=97;
>
> ****Sub****:
> postgres=# select supported_value from information_schema.sql_sizing
> where sizing_id=97;
> supported_value
> -----------------
> 0
>
> ~~
>
> Then drop and recreate and try to perform the above update again, it
> gets replicated:
>
> drop schema information_schema cascade;
> ./psql -d postgres -f ./../../src/backend/catalog/information_schema.sql -p 5433
>
> ****Pub****:
> ALTER PUBLICATION pub1 ADD TABLE information_schema.sql_sizing;
> select * from information_schema.sql_sizing where sizing_id=97;
> alter table information_schema.sql_sizing replica identity full;
> --This is replicated
> UPDATE information_schema.sql_sizing set supported_value=14 where sizing_id=97;
>
> ****Sub****:
> --This shows supported_value as 14
> postgres=# select supported_value from information_schema.sql_sizing
> where sizing_id=97;
> supported_value
> -----------------
> 14
Hmm, I might be missing something what why we do not want to publish
which is in information_shcema, especially when the internally created
schema is dropped then user can create his own schema with name
information-schema and create a bunch of tables in that so why do we
want to block those? I mean the example you showed here is pretty
much like a user created schema and table no? Or am I missing
something important?
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-17 09:44 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: shveta malik @ 2025-12-17 09:44 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Wed, Dec 17, 2025 at 9:59 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Tue, Dec 16, 2025 at 10:33 AM shveta malik <shveta.malik@gmail.com> wrote:
>
> > The OID check may be unreliable, as mentioned in the comment. I tested
> > this by dropping and recreating information_schema, and observed that
> > after recreation it became eligible for publication because its relid
> > no longer falls under FirstNormalObjectId. Steps:
> >
> > ****Pub****:
> > create publication pub1;
> > ALTER PUBLICATION pub1 ADD TABLE information_schema.sql_sizing;
> > select * from information_schema.sql_sizing where sizing_id=97;
> >
> > ****Sub****:
> > create subscription sub1 connection '...' publication pub1 with
> > (copy_data=false);
> > select * from information_schema.sql_sizing where sizing_id=97;
> >
> > ****Pub****:
> > alter table information_schema.sql_sizing replica identity full;
> > --this is not replicated.
> > UPDATE information_schema.sql_sizing set supported_value=12 where sizing_id=97;
> >
> > ****Sub****:
> > postgres=# select supported_value from information_schema.sql_sizing
> > where sizing_id=97;
> > supported_value
> > -----------------
> > 0
> >
> > ~~
> >
> > Then drop and recreate and try to perform the above update again, it
> > gets replicated:
> >
> > drop schema information_schema cascade;
> > ./psql -d postgres -f ./../../src/backend/catalog/information_schema.sql -p 5433
> >
> > ****Pub****:
> > ALTER PUBLICATION pub1 ADD TABLE information_schema.sql_sizing;
> > select * from information_schema.sql_sizing where sizing_id=97;
> > alter table information_schema.sql_sizing replica identity full;
> > --This is replicated
> > UPDATE information_schema.sql_sizing set supported_value=14 where sizing_id=97;
> >
> > ****Sub****:
> > --This shows supported_value as 14
> > postgres=# select supported_value from information_schema.sql_sizing
> > where sizing_id=97;
> > supported_value
> > -----------------
> > 14
>
> Hmm, I might be missing something what why we do not want to publish
> which is in information_shcema, especially when the internally created
> schema is dropped then user can create his own schema with name
> information-schema and create a bunch of tables in that so why do we
> want to block those? I mean the example you showed here is pretty
> much like a user created schema and table no? Or am I missing
> something important?
>
I don’t think a user intentionally dropping information_schema and
creating their own schema (with different definitions and tables) is a
practical scenario. While it isn’t explicitly restricted, I don’t see
a strong need for it. OTOH, there are scenarios where, after fixing
issues that affect the definition of information_schema on stable
branches, users may be asked to reload information_schema to apply the
updated definitions. One such case can be seen in [1].
Additionally, while reviewing the code, I noticed places where the
logic does not rely solely on relid being less than
FirstNormalObjectId. Instead, it performs name-based comparisons,
explicitly accounting for the possibility that information_schema may
have been dropped and reloaded. This further indicates that such
scenarios are considered practical. See [2].
And if such scenarios are possible, it might be worth considering
keeping the publish behavior consistent, both before and after a
reload of information_schema.
[1]:
https://www.postgresql.org/docs/9.1/release-9-1-2.html
[2]:
pg_upgrade has this:
static DataTypesUsageChecks data_types_usage_checks[] =
{
/*
* Look for composite types that were made during initdb *or* belong to
* information_schema; that's important in case information_schema was
* dropped and reloaded.
*
* The cutoff OID here should match the source cluster's value of
* FirstNormalObjectId. We hardcode it rather than using that C #define
* because, if that #define is ever changed, our own version's value is
* NOT what to use. Eventually we may need a test on the
source cluster's
* version to select the correct value.
*/
{
.status = gettext_noop("Checking for system-defined
composite types in user tables"),
.report_filename = "tables_using_composite.txt",
.base_query =
"SELECT t.oid FROM pg_catalog.pg_type t "
"LEFT JOIN pg_catalog.pg_namespace n ON t.typnamespace = n.oid "
" WHERE typtype = 'c' AND (t.oid < 16384 OR nspname =
'information_schema')",
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-17 09:58 Dilip Kumar <dilipbalaut@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-12-17 09:58 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Wed, Dec 17, 2025 at 3:14 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> I don’t think a user intentionally dropping information_schema and
> creating their own schema (with different definitions and tables) is a
> practical scenario. While it isn’t explicitly restricted, I don’t see
> a strong need for it. OTOH, there are scenarios where, after fixing
> issues that affect the definition of information_schema on stable
> branches, users may be asked to reload information_schema to apply the
> updated definitions. One such case can be seen in [1].
>
> Additionally, while reviewing the code, I noticed places where the
> logic does not rely solely on relid being less than
> FirstNormalObjectId. Instead, it performs name-based comparisons,
> explicitly accounting for the possibility that information_schema may
> have been dropped and reloaded. This further indicates that such
> scenarios are considered practical. See [2].
> And if such scenarios are possible, it might be worth considering
> keeping the publish behavior consistent, both before and after a
> reload of information_schema.
>
> [1]:
> https://www.postgresql.org/docs/9.1/release-9-1-2.html
>
> [2]:
> pg_upgrade has this:
> static DataTypesUsageChecks data_types_usage_checks[] =
> {
> /*
> * Look for composite types that were made during initdb *or* belong to
> * information_schema; that's important in case information_schema was
> * dropped and reloaded.
> *
> * The cutoff OID here should match the source cluster's value of
> * FirstNormalObjectId. We hardcode it rather than using that C #define
> * because, if that #define is ever changed, our own version's value is
> * NOT what to use. Eventually we may need a test on the
> source cluster's
> * version to select the correct value.
> */
> {
> .status = gettext_noop("Checking for system-defined
> composite types in user tables"),
> .report_filename = "tables_using_composite.txt",
> .base_query =
> "SELECT t.oid FROM pg_catalog.pg_type t "
> "LEFT JOIN pg_catalog.pg_namespace n ON t.typnamespace = n.oid "
> " WHERE typtype = 'c' AND (t.oid < 16384 OR nspname =
> 'information_schema')",
Yeah I agree with your theory. While the system allows users to
manually create an information_schema or place objects within it, we
are establishing that anything inside this schema will be treated as
an internal object. If a user chooses to bypass these conventions and
then finds the objects are not handled like standard user tables, it
constitutes a usage error rather than a system bug.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-17 10:19 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 0 replies; 912+ messages in thread
From: shveta malik @ 2025-12-17 10:19 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Wed, Dec 17, 2025 at 3:29 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Wed, Dec 17, 2025 at 3:14 PM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > I don’t think a user intentionally dropping information_schema and
> > creating their own schema (with different definitions and tables) is a
> > practical scenario. While it isn’t explicitly restricted, I don’t see
> > a strong need for it. OTOH, there are scenarios where, after fixing
> > issues that affect the definition of information_schema on stable
> > branches, users may be asked to reload information_schema to apply the
> > updated definitions. One such case can be seen in [1].
> >
> > Additionally, while reviewing the code, I noticed places where the
> > logic does not rely solely on relid being less than
> > FirstNormalObjectId. Instead, it performs name-based comparisons,
> > explicitly accounting for the possibility that information_schema may
> > have been dropped and reloaded. This further indicates that such
> > scenarios are considered practical. See [2].
> > And if such scenarios are possible, it might be worth considering
> > keeping the publish behavior consistent, both before and after a
> > reload of information_schema.
> >
> > [1]:
> > https://www.postgresql.org/docs/9.1/release-9-1-2.html
> >
> > [2]:
> > pg_upgrade has this:
> > static DataTypesUsageChecks data_types_usage_checks[] =
> > {
> > /*
> > * Look for composite types that were made during initdb *or* belong to
> > * information_schema; that's important in case information_schema was
> > * dropped and reloaded.
> > *
> > * The cutoff OID here should match the source cluster's value of
> > * FirstNormalObjectId. We hardcode it rather than using that C #define
> > * because, if that #define is ever changed, our own version's value is
> > * NOT what to use. Eventually we may need a test on the
> > source cluster's
> > * version to select the correct value.
> > */
> > {
> > .status = gettext_noop("Checking for system-defined
> > composite types in user tables"),
> > .report_filename = "tables_using_composite.txt",
> > .base_query =
> > "SELECT t.oid FROM pg_catalog.pg_type t "
> > "LEFT JOIN pg_catalog.pg_namespace n ON t.typnamespace = n.oid "
> > " WHERE typtype = 'c' AND (t.oid < 16384 OR nspname =
> > 'information_schema')",
>
> Yeah I agree with your theory. While the system allows users to
> manually create an information_schema or place objects within it, we
> are establishing that anything inside this schema will be treated as
> an internal object. If a user chooses to bypass these conventions and
> then finds the objects are not handled like standard user tables, it
> constitutes a usage error rather than a system bug.
Yes, I think so as well. IIUC, we wouldn’t be establishing anything
new here; this behavior is already established. If we look at the code
paths that reference information_schema, it is consistently treated as
similar to system schema rather than a user schema. A few examples
include XML_VISIBLE_SCHEMAS_EXCLUDE, selectDumpableNamespace,
data_types_usage_checks, describeFunctions, describeAggregates, and
others.
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-18 09:09 Dilip Kumar <dilipbalaut@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
0 siblings, 4 replies; 912+ messages in thread
From: Dilip Kumar @ 2025-12-18 09:09 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, Dec 16, 2025 at 9:51 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Mon, Dec 15, 2025 at 5:11 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> We could do this as a first step. See the proposal in email [1] where
> we have discussed having two options instead of one. The first option
> will be conflict_log_format and the values would be log and table. In
> this case, the table would be an internally generated one.
>
> [1] - https://www.postgresql.org/message-id/CAA4eK1KwqE2y%3D_k5Xc%3Def0S5JXG2x%3DoeWpDJ%2B%3D5k6Anzaw2gdw%...
So I have put more thought on this and here is what I am proposing
1) Subscription Parameter: Son in first version the subscription
parameter will be named 'conflict_log_format' which will accept
'log/table/both' default option would be log.
2) If conflict_log_format = log is provided then we do not need to do
anything as this would work by default
3) If conflict_log_format = table/both is provided then we will
generate a internal table name i.e. conflict_log_table_$subid$ and the
table will be created in the current schema
4) in pg_subscription we will still keep 2 field a) namespace id of
the conflict log table b) the conflict log format = 'log/table'both'
5) If option is table/both the name can be generated on the fly
whether we are creating the table or inserting conflict into the
table.
Question:
1) Shall we create a conflict log table in the current schema or we
should consider anything else, IMHO the current schema should be fine
and in the future when we add an option for conflict_log_table we will
support schema qualified names as well?
2) In catalog I am storing the "conflict_log_format" option as a text
field, is there any better way so that we can store in fixed format
maybe enum value as an integer we can do e.g. from below enum we can
store the integer value in system catalog for "conflict_log_format"
field, not sure if we have done such think anywhere else?
typedef enum ConflictLogFormat
{
CONFLICT_LOG_FORMAT_DEFAULT = 0,
CONFLICT_LOG_FORMAT_LOG,
CONFLICT_LOG_FORMAT_TABLE,
CONFLICT_LOG_FORMAT_BOTH
} ConflictLogFormat;
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-18 09:55 Dilip Kumar <dilipbalaut@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
3 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-12-18 09:55 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, Dec 18, 2025 at 2:39 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Tue, Dec 16, 2025 at 9:51 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
> >
> > On Mon, Dec 15, 2025 at 5:11 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> >
> > We could do this as a first step. See the proposal in email [1] where
> > we have discussed having two options instead of one. The first option
> > will be conflict_log_format and the values would be log and table. In
> > this case, the table would be an internally generated one.
> >
> > [1] - https://www.postgresql.org/message-id/CAA4eK1KwqE2y%3D_k5Xc%3Def0S5JXG2x%3DoeWpDJ%2B%3D5k6Anzaw2gdw%...
>
> So I have put more thought on this and here is what I am proposing
>
> 1) Subscription Parameter: Son in first version the subscription
> parameter will be named 'conflict_log_format' which will accept
> 'log/table/both' default option would be log.
> 2) If conflict_log_format = log is provided then we do not need to do
> anything as this would work by default
> 3) If conflict_log_format = table/both is provided then we will
> generate a internal table name i.e. conflict_log_table_$subid$ and the
> table will be created in the current schema
> 4) in pg_subscription we will still keep 2 field a) namespace id of
> the conflict log table b) the conflict log format = 'log/table'both'
> 5) If option is table/both the name can be generated on the fly
> whether we are creating the table or inserting conflict into the
> table.
>
> Question:
> 1) Shall we create a conflict log table in the current schema or we
> should consider anything else, IMHO the current schema should be fine
> and in the future when we add an option for conflict_log_table we will
> support schema qualified names as well?
> 2) In catalog I am storing the "conflict_log_format" option as a text
> field, is there any better way so that we can store in fixed format
> maybe enum value as an integer we can do e.g. from below enum we can
> store the integer value in system catalog for "conflict_log_format"
> field, not sure if we have done such think anywhere else?
>
> typedef enum ConflictLogFormat
> {
> CONFLICT_LOG_FORMAT_DEFAULT = 0,
> CONFLICT_LOG_FORMAT_LOG,
> CONFLICT_LOG_FORMAT_TABLE,
> CONFLICT_LOG_FORMAT_BOTH
> } ConflictLogFormat;
While exploring other kinds of options I think we can make it a char
something like relkind as shown below, any other opinion on the same?
#define CONFLICT_LOG_FORMAT_LOG = 'l'
#define CONFLICT_LOG_FORMAT_TABLE = 't'
#define CONFLICT_LOG_FORMAT_BOTH = 'b'
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-18 11:06 Amit Kapila <amit.kapila16@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 0 replies; 912+ messages in thread
From: Amit Kapila @ 2025-12-18 11:06 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, Dec 18, 2025 at 3:25 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Thu, Dec 18, 2025 at 2:39 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Tue, Dec 16, 2025 at 9:51 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > >
> > > On Mon, Dec 15, 2025 at 5:11 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > >
> > > We could do this as a first step. See the proposal in email [1] where
> > > we have discussed having two options instead of one. The first option
> > > will be conflict_log_format and the values would be log and table. In
> > > this case, the table would be an internally generated one.
> > >
> > > [1] - https://www.postgresql.org/message-id/CAA4eK1KwqE2y%3D_k5Xc%3Def0S5JXG2x%3DoeWpDJ%2B%3D5k6Anzaw2gdw%...
> >
> > So I have put more thought on this and here is what I am proposing
> >
> > 1) Subscription Parameter: Son in first version the subscription
> > parameter will be named 'conflict_log_format' which will accept
> > 'log/table/both' default option would be log.
> > 2) If conflict_log_format = log is provided then we do not need to do
> > anything as this would work by default
> > 3) If conflict_log_format = table/both is provided then we will
> > generate a internal table name i.e. conflict_log_table_$subid$ and the
> > table will be created in the current schema
> > 4) in pg_subscription we will still keep 2 field a) namespace id of
> > the conflict log table b) the conflict log format = 'log/table'both'
> > 5) If option is table/both the name can be generated on the fly
> > whether we are creating the table or inserting conflict into the
> > table.
> >
> > Question:
> > 1) Shall we create a conflict log table in the current schema or we
> > should consider anything else, IMHO the current schema should be fine
> > and in the future when we add an option for conflict_log_table we will
> > support schema qualified names as well?
> > 2) In catalog I am storing the "conflict_log_format" option as a text
> > field, is there any better way so that we can store in fixed format
> > maybe enum value as an integer we can do e.g. from below enum we can
> > store the integer value in system catalog for "conflict_log_format"
> > field, not sure if we have done such think anywhere else?
> >
> > typedef enum ConflictLogFormat
> > {
> > CONFLICT_LOG_FORMAT_DEFAULT = 0,
> > CONFLICT_LOG_FORMAT_LOG,
> > CONFLICT_LOG_FORMAT_TABLE,
> > CONFLICT_LOG_FORMAT_BOTH
> > } ConflictLogFormat;
>
> While exploring other kinds of options I think we can make it a char
> something like relkind as shown below, any other opinion on the same?
>
> #define CONFLICT_LOG_FORMAT_LOG = 'l'
> #define CONFLICT_LOG_FORMAT_TABLE = 't'
> #define CONFLICT_LOG_FORMAT_BOTH = 'b'
>
+1. Also, we should expose this to users with a type as enum similar
to auto_explain.log_format or publish_generated_columns.
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-18 23:07 Masahiko Sawada <sawada.mshk@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
3 siblings, 1 reply; 912+ messages in thread
From: Masahiko Sawada @ 2025-12-18 23:07 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, Dec 18, 2025 at 1:09 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Tue, Dec 16, 2025 at 9:51 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
> >
> > On Mon, Dec 15, 2025 at 5:11 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> >
> > We could do this as a first step. See the proposal in email [1] where
> > we have discussed having two options instead of one. The first option
> > will be conflict_log_format and the values would be log and table. In
> > this case, the table would be an internally generated one.
> >
> > [1] - https://www.postgresql.org/message-id/CAA4eK1KwqE2y%3D_k5Xc%3Def0S5JXG2x%3DoeWpDJ%2B%3D5k6Anzaw2gdw%...
>
> So I have put more thought on this and here is what I am proposing
>
> 1) Subscription Parameter: Son in first version the subscription
> parameter will be named 'conflict_log_format' which will accept
> 'log/table/both' default option would be log.
> 2) If conflict_log_format = log is provided then we do not need to do
> anything as this would work by default
> 3) If conflict_log_format = table/both is provided then we will
> generate a internal table name i.e. conflict_log_table_$subid$ and the
> table will be created in the current schema
> 4) in pg_subscription we will still keep 2 field a) namespace id of
> the conflict log table b) the conflict log format = 'log/table'both'
> 5) If option is table/both the name can be generated on the fly
> whether we are creating the table or inserting conflict into the
> table.
I have a question: who will be the owner of the conflict log table? I
assume that the subscription owner would own the conflict log table
and the conflict logs are inserted by the owner but not by the table
owner, is that right?
>
> Question:
> 1) Shall we create a conflict log table in the current schema or we
> should consider anything else, IMHO the current schema should be fine
> and in the future when we add an option for conflict_log_table we will
> support schema qualified names as well?
Some questions:
If the same name table already exists, CREATE SUBSCRIPTION will fail, right?
Can the conflict log table be used like normal user tables (e.g.,
creating a trigger/a foreign key, running vacuum, ALTER TABLE etc.)?
> 2) In catalog I am storing the "conflict_log_format" option as a text
> field, is there any better way so that we can store in fixed format
> maybe enum value as an integer we can do e.g. from below enum we can
> store the integer value in system catalog for "conflict_log_format"
> field, not sure if we have done such think anywhere else?
>
> typedef enum ConflictLogFormat
> {
> CONFLICT_LOG_FORMAT_DEFAULT = 0,
> CONFLICT_LOG_FORMAT_LOG,
> CONFLICT_LOG_FORMAT_TABLE,
> CONFLICT_LOG_FORMAT_BOTH
> } ConflictLogFormat;
How about making conflict_log_format accept a list of destinations
instead of having the 'both' option in case where we might add more
destination options in the future?
It seems to me that conflict_log_destination sounds better.
Regards,
--
Masahiko Sawada
Amazon Web Services: https://aws.amazon.com
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-19 00:04 Peter Smith <smithpb2250@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
3 siblings, 1 reply; 912+ messages in thread
From: Peter Smith @ 2025-12-19 00:04 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, Dec 18, 2025 at 8:09 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Tue, Dec 16, 2025 at 9:51 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
> >
> > On Mon, Dec 15, 2025 at 5:11 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> >
> > We could do this as a first step. See the proposal in email [1] where
> > we have discussed having two options instead of one. The first option
> > will be conflict_log_format and the values would be log and table. In
> > this case, the table would be an internally generated one.
> >
> > [1] - https://www.postgresql.org/message-id/CAA4eK1KwqE2y%3D_k5Xc%3Def0S5JXG2x%3DoeWpDJ%2B%3D5k6Anzaw2gdw%...
>
> So I have put more thought on this and here is what I am proposing
>
> 1) Subscription Parameter: Son in first version the subscription
> parameter will be named 'conflict_log_format' which will accept
> 'log/table/both' default option would be log.
> 2) If conflict_log_format = log is provided then we do not need to do
> anything as this would work by default
> 3) If conflict_log_format = table/both is provided then we will
> generate a internal table name i.e. conflict_log_table_$subid$ and the
> table will be created in the current schema
> 4) in pg_subscription we will still keep 2 field a) namespace id of
> the conflict log table b) the conflict log format = 'log/table'both'
> 5) If option is table/both the name can be generated on the fly
> whether we are creating the table or inserting conflict into the
> table.
IIUC, previously you had a "none" value which was a way to "turn off"
any CLT previously defined. How can users do that now with
log/table/both? Would they have to reassign (the default) "log"? That
seems a bit strange.
The word "both" option is too restrictive. What if in the future you
added a 3rd kind of destination -- then what does "both" mean?
Maybe the destination list idea of Sawda-San's is better.
a) it resolves the "none" issue -- e.g., empty string means revert to
default CLT behaviour
b) it resolves the "both" issue.
======
Kind Regards,
Peter Smith.
Fujitsu Australia
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-19 00:28 Peter Smith <smithpb2250@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
3 siblings, 0 replies; 912+ messages in thread
From: Peter Smith @ 2025-12-19 00:28 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, Dec 18, 2025 at 8:09 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
...
>
> Question:
> 1) Shall we create a conflict log table in the current schema or we
> should consider anything else, IMHO the current schema should be fine
> and in the future when we add an option for conflict_log_table we will
> support schema qualified names as well?
You might be able to avoid a proliferation of related options (such as
conflict_log_table) if you renamed the main option to
"conflict_log_destination" like Sawada-San was suggesting.
e.g.
conflict_log_destimation="table" --> use default table named by code
conflict_log_destimation="table=myschema.mytable" --> table name
nominated by user
e.g. if wanted maybe this idea can extend to logs too.
conflict_log_destimation="log" --> use default pg log files
conflict_log_destimation="log=my_clt_log.txt" --> write conflicts to a
separate log file nominated by user
======
Kind Regards,
Peter Smith.
Fujitsu Australia
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-19 04:09 Amit Kapila <amit.kapila16@gmail.com>
parent: Masahiko Sawada <sawada.mshk@gmail.com>
0 siblings, 2 replies; 912+ messages in thread
From: Amit Kapila @ 2025-12-19 04:09 UTC (permalink / raw)
To: Masahiko Sawada <sawada.mshk@gmail.com>; +Cc: Dilip Kumar <dilipbalaut@gmail.com>; shveta malik <shveta.malik@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Fri, Dec 19, 2025 at 4:38 AM Masahiko Sawada <sawada.mshk@gmail.com> wrote:
>
> On Thu, Dec 18, 2025 at 1:09 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
>
> > 2) In catalog I am storing the "conflict_log_format" option as a text
> > field, is there any better way so that we can store in fixed format
> > maybe enum value as an integer we can do e.g. from below enum we can
> > store the integer value in system catalog for "conflict_log_format"
> > field, not sure if we have done such think anywhere else?
> >
> > typedef enum ConflictLogFormat
> > {
> > CONFLICT_LOG_FORMAT_DEFAULT = 0,
> > CONFLICT_LOG_FORMAT_LOG,
> > CONFLICT_LOG_FORMAT_TABLE,
> > CONFLICT_LOG_FORMAT_BOTH
> > } ConflictLogFormat;
>
> How about making conflict_log_format accept a list of destinations
> instead of having the 'both' option in case where we might add more
> destination options in the future?
>
> It seems to me that conflict_log_destination sounds better.
>
Yeah, this is worth considering. But say, we need to extend it so that
the conflict data goes in xml format file instead of standard log then
won't it look a bit odd to specify via conflict_log_destination. I
thought we could name it similar to the existing
auto_explain.log_format.
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-19 04:23 Dilip Kumar <dilipbalaut@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-12-19 04:23 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: Masahiko Sawada <sawada.mshk@gmail.com>; shveta malik <shveta.malik@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Fri, Dec 19, 2025 at 9:40 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Fri, Dec 19, 2025 at 4:38 AM Masahiko Sawada <sawada.mshk@gmail.com> wrote:
> >
> > On Thu, Dec 18, 2025 at 1:09 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> >
> > > 2) In catalog I am storing the "conflict_log_format" option as a text
> > > field, is there any better way so that we can store in fixed format
> > > maybe enum value as an integer we can do e.g. from below enum we can
> > > store the integer value in system catalog for "conflict_log_format"
> > > field, not sure if we have done such think anywhere else?
> > >
> > > typedef enum ConflictLogFormat
> > > {
> > > CONFLICT_LOG_FORMAT_DEFAULT = 0,
> > > CONFLICT_LOG_FORMAT_LOG,
> > > CONFLICT_LOG_FORMAT_TABLE,
> > > CONFLICT_LOG_FORMAT_BOTH
> > > } ConflictLogFormat;
> >
> > How about making conflict_log_format accept a list of destinations
> > instead of having the 'both' option in case where we might add more
> > destination options in the future?
> >
> > It seems to me that conflict_log_destination sounds better.
> >
>
> Yeah, this is worth considering. But say, we need to extend it so that
> the conflict data goes in xml format file instead of standard log then
> won't it look a bit odd to specify via conflict_log_destination. I
> thought we could name it similar to the existing
> auto_explain.log_format.
IMHO conflict_log_destination sounds more appropriate considering we
are talking about the log destination instead of format no? And the
option could be log/table/file etc, and for now we can just stick to
log/table. And in future we can extend it by supporting extra options
like destination_name, where we can provide table name or file name
etc. So let me list down all the points which need consensus.
1. What should be the name of the option 'conflict_log_destination' vs
'conflict_log_format'
2. Do we want to support multi destination then providing string like
'conflict_log_destination = 'log,table,..' make more sense but then we
would have to store as a string in catalog and parse it everytime we
insert conflicts or alter subscription OTOH currently I have just
support single option log/table/both which make things much easy
because then in catalog we can store as a single char field and don't
need any parsing. And since the input are taken as a string itself,
even if in future we want to support more options like 'log,table,..'
it would be backward compatible with old options.
3. Do we want to support 'none' destinations? i.e. do not log to anywhere?
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-19 04:25 Dilip Kumar <dilipbalaut@gmail.com>
parent: Peter Smith <smithpb2250@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-12-19 04:25 UTC (permalink / raw)
To: Peter Smith <smithpb2250@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Fri, Dec 19, 2025 at 5:35 AM Peter Smith <smithpb2250@gmail.com> wrote:
>
> On Thu, Dec 18, 2025 at 8:09 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Tue, Dec 16, 2025 at 9:51 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > >
> > > On Mon, Dec 15, 2025 at 5:11 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > >
> > > We could do this as a first step. See the proposal in email [1] where
> > > we have discussed having two options instead of one. The first option
> > > will be conflict_log_format and the values would be log and table. In
> > > this case, the table would be an internally generated one.
> > >
> > > [1] - https://www.postgresql.org/message-id/CAA4eK1KwqE2y%3D_k5Xc%3Def0S5JXG2x%3DoeWpDJ%2B%3D5k6Anzaw2gdw%...
> >
> > So I have put more thought on this and here is what I am proposing
> >
> > 1) Subscription Parameter: Son in first version the subscription
> > parameter will be named 'conflict_log_format' which will accept
> > 'log/table/both' default option would be log.
> > 2) If conflict_log_format = log is provided then we do not need to do
> > anything as this would work by default
> > 3) If conflict_log_format = table/both is provided then we will
> > generate a internal table name i.e. conflict_log_table_$subid$ and the
> > table will be created in the current schema
> > 4) in pg_subscription we will still keep 2 field a) namespace id of
> > the conflict log table b) the conflict log format = 'log/table'both'
> > 5) If option is table/both the name can be generated on the fly
> > whether we are creating the table or inserting conflict into the
> > table.
>
> IIUC, previously you had a "none" value which was a way to "turn off"
> any CLT previously defined. How can users do that now with
> log/table/both? Would they have to reassign (the default) "log"? That
> seems a bit strange.
Previously we were supporting only conflict log tables and by default
it was always sent to log. And "none" was used for clearing the
conflict log table option; it was never meant for not logging anywhere
it was meant to say that there is no conflict log table. Now also we
can have another option as none but I intentionally avoided it
considering we want to support the case where we don't want to log it
at all, maybe that's not a bad idea either. Let's see what others
think about it.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-19 04:52 shveta malik <shveta.malik@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
1 sibling, 0 replies; 912+ messages in thread
From: shveta malik @ 2025-12-19 04:52 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: Masahiko Sawada <sawada.mshk@gmail.com>; Dilip Kumar <dilipbalaut@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Fri, Dec 19, 2025 at 9:40 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Fri, Dec 19, 2025 at 4:38 AM Masahiko Sawada <sawada.mshk@gmail.com> wrote:
> >
> > On Thu, Dec 18, 2025 at 1:09 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> >
> > > 2) In catalog I am storing the "conflict_log_format" option as a text
> > > field, is there any better way so that we can store in fixed format
> > > maybe enum value as an integer we can do e.g. from below enum we can
> > > store the integer value in system catalog for "conflict_log_format"
> > > field, not sure if we have done such think anywhere else?
> > >
> > > typedef enum ConflictLogFormat
> > > {
> > > CONFLICT_LOG_FORMAT_DEFAULT = 0,
> > > CONFLICT_LOG_FORMAT_LOG,
> > > CONFLICT_LOG_FORMAT_TABLE,
> > > CONFLICT_LOG_FORMAT_BOTH
> > > } ConflictLogFormat;
> >
> > How about making conflict_log_format accept a list of destinations
> > instead of having the 'both' option in case where we might add more
> > destination options in the future?
> >
> > It seems to me that conflict_log_destination sounds better.
> >
>
> Yeah, this is worth considering. But say, we need to extend it so that
> the conflict data goes in xml format file instead of standard log then
> won't it look a bit odd to specify via conflict_log_destination. I
> thought we could name it similar to the existing
> auto_explain.log_format.
>
One option could be to separate destination and format:
conflict_log_history.destination : log/table
conflict_log_history.format : xml/json/text etc
Another option could be to use a single parameter,
'conflict_log_destination', with values such as:
table, xmllog, jsonlog, stderr/textlog
(where stderr corresponds to logging to log/postgresql.log, similar to
log_destination at [1]). I prefer this approach.
[1]: https://www.postgresql.org/docs/18/runtime-config-logging.html
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-19 05:10 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 2 replies; 912+ messages in thread
From: shveta malik @ 2025-12-19 05:10 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Fri, Dec 19, 2025 at 9:53 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Fri, Dec 19, 2025 at 9:40 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
> >
> > On Fri, Dec 19, 2025 at 4:38 AM Masahiko Sawada <sawada.mshk@gmail.com> wrote:
> > >
> > > On Thu, Dec 18, 2025 at 1:09 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > >
> > >
> > > > 2) In catalog I am storing the "conflict_log_format" option as a text
> > > > field, is there any better way so that we can store in fixed format
> > > > maybe enum value as an integer we can do e.g. from below enum we can
> > > > store the integer value in system catalog for "conflict_log_format"
> > > > field, not sure if we have done such think anywhere else?
> > > >
> > > > typedef enum ConflictLogFormat
> > > > {
> > > > CONFLICT_LOG_FORMAT_DEFAULT = 0,
> > > > CONFLICT_LOG_FORMAT_LOG,
> > > > CONFLICT_LOG_FORMAT_TABLE,
> > > > CONFLICT_LOG_FORMAT_BOTH
> > > > } ConflictLogFormat;
> > >
> > > How about making conflict_log_format accept a list of destinations
> > > instead of having the 'both' option in case where we might add more
> > > destination options in the future?
> > >
> > > It seems to me that conflict_log_destination sounds better.
> > >
> >
> > Yeah, this is worth considering. But say, we need to extend it so that
> > the conflict data goes in xml format file instead of standard log then
> > won't it look a bit odd to specify via conflict_log_destination. I
> > thought we could name it similar to the existing
> > auto_explain.log_format.
>
> IMHO conflict_log_destination sounds more appropriate considering we
> are talking about the log destination instead of format no? And the
> option could be log/table/file etc, and for now we can just stick to
> log/table. And in future we can extend it by supporting extra options
> like destination_name, where we can provide table name or file name
> etc. So let me list down all the points which need consensus.
>
> 1. What should be the name of the option 'conflict_log_destination' vs
> 'conflict_log_format'
I prefer conflcit_log_destination.
> 2. Do we want to support multi destination then providing string like
> 'conflict_log_destination = 'log,table,..' make more sense but then we
> would have to store as a string in catalog and parse it everytime we
> insert conflicts or alter subscription OTOH currently I have just
> support single option log/table/both which make things much easy
> because then in catalog we can store as a single char field and don't
> need any parsing. And since the input are taken as a string itself,
> even if in future we want to support more options like 'log,table,..'
> it would be backward compatible with old options.
I feel, combination of options might be a good idea, similar to how
'log_destination' provides. But it can be done in future versions and
the first draft can be a simple one.
> 3. Do we want to support 'none' destinations? i.e. do not log to anywhere?
IMO, conflict information is an important piece of information to
diagnose data divergence and thus should be logged always.
Let's wait for others' opinions.
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-19 05:42 Peter Smith <smithpb2250@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Peter Smith @ 2025-12-19 05:42 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Fri, Dec 19, 2025 at 3:25 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Fri, Dec 19, 2025 at 5:35 AM Peter Smith <smithpb2250@gmail.com> wrote:
> >
> > On Thu, Dec 18, 2025 at 8:09 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > On Tue, Dec 16, 2025 at 9:51 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > > >
> > > > On Mon, Dec 15, 2025 at 5:11 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > >
> > > > We could do this as a first step. See the proposal in email [1] where
> > > > we have discussed having two options instead of one. The first option
> > > > will be conflict_log_format and the values would be log and table. In
> > > > this case, the table would be an internally generated one.
> > > >
> > > > [1] - https://www.postgresql.org/message-id/CAA4eK1KwqE2y%3D_k5Xc%3Def0S5JXG2x%3DoeWpDJ%2B%3D5k6Anzaw2gdw%...
> > >
> > > So I have put more thought on this and here is what I am proposing
> > >
> > > 1) Subscription Parameter: Son in first version the subscription
> > > parameter will be named 'conflict_log_format' which will accept
> > > 'log/table/both' default option would be log.
> > > 2) If conflict_log_format = log is provided then we do not need to do
> > > anything as this would work by default
> > > 3) If conflict_log_format = table/both is provided then we will
> > > generate a internal table name i.e. conflict_log_table_$subid$ and the
> > > table will be created in the current schema
> > > 4) in pg_subscription we will still keep 2 field a) namespace id of
> > > the conflict log table b) the conflict log format = 'log/table'both'
> > > 5) If option is table/both the name can be generated on the fly
> > > whether we are creating the table or inserting conflict into the
> > > table.
> >
> > IIUC, previously you had a "none" value which was a way to "turn off"
> > any CLT previously defined. How can users do that now with
> > log/table/both? Would they have to reassign (the default) "log"? That
> > seems a bit strange.
>
> Previously we were supporting only conflict log tables and by default
> it was always sent to log. And "none" was used for clearing the
> conflict log table option; it was never meant for not logging anywhere
> it was meant to say that there is no conflict log table. Now also we
> can have another option as none but I intentionally avoided it
> considering we want to support the case where we don't want to log it
> at all, maybe that's not a bad idea either. Let's see what others
> think about it.
>
I didn't mean to suggest we should allow "not logging anywhere". I
only wanted to ask how the user is expected to revert the conflict
logging back to the default after they had set it to something else.
e.g.
CREATE SUBSCRIPTION mysub2 ... WITH(conflict_log_destination=table)
Now, how to ALTER SUBSCRIPTION to revert that back to default?
It seems there is no "reset to default" so is the user required to do
this explicitly?
ALTER SUBSCRIPTION mysub2 SET (conflict_log_destination=log);
Maybe that's fine --- I was just looking for some examples/clarification.
======
Kind Regards,
Peter Smith.
Fujitsu Australia
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-19 06:14 Dilip Kumar <dilipbalaut@gmail.com>
parent: Peter Smith <smithpb2250@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-12-19 06:14 UTC (permalink / raw)
To: Peter Smith <smithpb2250@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Fri, Dec 19, 2025 at 11:12 AM Peter Smith <smithpb2250@gmail.com> wrote:
>
> I didn't mean to suggest we should allow "not logging anywhere". I
> only wanted to ask how the user is expected to revert the conflict
> logging back to the default after they had set it to something else.
Okay understood, thanks for the clarification.
> e.g.
>
> CREATE SUBSCRIPTION mysub2 ... WITH(conflict_log_destination=table)
> Now, how to ALTER SUBSCRIPTION to revert that back to default?
>
> It seems there is no "reset to default" so is the user required to do
> this explicitly?
> ALTER SUBSCRIPTION mysub2 SET (conflict_log_destination=log);
>
> Maybe that's fine --- I was just looking for some examples/clarification.
Yeah this is the way, IMHO it looks fine to me.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-19 06:19 Amit Kapila <amit.kapila16@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: Amit Kapila @ 2025-12-19 06:19 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Dilip Kumar <dilipbalaut@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Fri, Dec 19, 2025 at 10:40 AM shveta malik <shveta.malik@gmail.com> wrote:
>
> On Fri, Dec 19, 2025 at 9:53 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
>
> > 2. Do we want to support multi destination then providing string like
> > 'conflict_log_destination = 'log,table,..' make more sense but then we
> > would have to store as a string in catalog and parse it everytime we
> > insert conflicts or alter subscription OTOH currently I have just
> > support single option log/table/both which make things much easy
> > because then in catalog we can store as a single char field and don't
> > need any parsing. And since the input are taken as a string itself,
> > even if in future we want to support more options like 'log,table,..'
> > it would be backward compatible with old options.
>
> I feel, combination of options might be a good idea, similar to how
> 'log_destination' provides. But it can be done in future versions and
> the first draft can be a simple one.
>
Considering the future extension of storing conflict information in
multiple places, it would be good to follow log_destination. Yes, it
is more work now but I feel that will be future-proof.
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-19 06:22 Amit Kapila <amit.kapila16@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 0 replies; 912+ messages in thread
From: Amit Kapila @ 2025-12-19 06:22 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Peter Smith <smithpb2250@gmail.com>; shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Fri, Dec 19, 2025 at 11:44 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Fri, Dec 19, 2025 at 11:12 AM Peter Smith <smithpb2250@gmail.com> wrote:
> >
> > I didn't mean to suggest we should allow "not logging anywhere". I
> > only wanted to ask how the user is expected to revert the conflict
> > logging back to the default after they had set it to something else.
>
> Okay understood, thanks for the clarification.
>
> > e.g.
> >
> > CREATE SUBSCRIPTION mysub2 ... WITH(conflict_log_destination=table)
> > Now, how to ALTER SUBSCRIPTION to revert that back to default?
> >
> > It seems there is no "reset to default" so is the user required to do
> > this explicitly?
> > ALTER SUBSCRIPTION mysub2 SET (conflict_log_destination=log);
> >
> > Maybe that's fine --- I was just looking for some examples/clarification.
>
> Yeah this is the way, IMHO it looks fine to me.
>
How about considering log as default, so even if the user resets it
via "ALTER SUBSCRIPTION mysub2 SET (conflict_log_destination='');", we
send it to LOG as we are doing currently in HEAD? This means
conflict_log_destination='' or conflict_log_destination='log' means
the same.
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-19 06:24 Dilip Kumar <dilipbalaut@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-12-19 06:24 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Fri, Dec 19, 2025 at 10:40 AM shveta malik <shveta.malik@gmail.com> wrote:
> > 1. What should be the name of the option 'conflict_log_destination' vs
> > 'conflict_log_format'
>
> I prefer conflcit_log_destination.
>
> > 2. Do we want to support multi destination then providing string like
> > 'conflict_log_destination = 'log,table,..' make more sense but then we
> > would have to store as a string in catalog and parse it everytime we
> > insert conflicts or alter subscription OTOH currently I have just
> > support single option log/table/both which make things much easy
> > because then in catalog we can store as a single char field and don't
> > need any parsing. And since the input are taken as a string itself,
> > even if in future we want to support more options like 'log,table,..'
> > it would be backward compatible with old options.
>
> I feel, combination of options might be a good idea, similar to how
> 'log_destination' provides. But it can be done in future versions and
> the first draft can be a simple one.
>
> > 3. Do we want to support 'none' destinations? i.e. do not log to anywhere?
>
> IMO, conflict information is an important piece of information to
> diagnose data divergence and thus should be logged always.
>
> Let's wait for others' opinions.
Thanks Shveta for you opinion,
Here is what I propose considering balance between simplicity with
future scalability:
1. Retain 'conflict_log_destination' as the option name.
2. Current supported values include 'log', 'table', or 'all' (which
directs output to both locations). But we will not support comma
separated values in the first version.
3. By treating this as a string, we can eventually support
comma-separated values like 'log, table, new_option'. This approach
maintains a simple design by avoiding immediate need of parsing the
comma separated options while ensuring extensibility.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-19 08:27 Masahiko Sawada <sawada.mshk@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 0 replies; 912+ messages in thread
From: Masahiko Sawada @ 2025-12-19 08:27 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, Dec 18, 2025 at 10:24 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Fri, Dec 19, 2025 at 10:40 AM shveta malik <shveta.malik@gmail.com> wrote:
>
> > > 1. What should be the name of the option 'conflict_log_destination' vs
> > > 'conflict_log_format'
> >
> > I prefer conflcit_log_destination.
> >
> > > 2. Do we want to support multi destination then providing string like
> > > 'conflict_log_destination = 'log,table,..' make more sense but then we
> > > would have to store as a string in catalog and parse it everytime we
> > > insert conflicts or alter subscription OTOH currently I have just
> > > support single option log/table/both which make things much easy
> > > because then in catalog we can store as a single char field and don't
> > > need any parsing. And since the input are taken as a string itself,
> > > even if in future we want to support more options like 'log,table,..'
> > > it would be backward compatible with old options.
> >
> > I feel, combination of options might be a good idea, similar to how
> > 'log_destination' provides. But it can be done in future versions and
> > the first draft can be a simple one.
> >
> > > 3. Do we want to support 'none' destinations? i.e. do not log to anywhere?
> >
> > IMO, conflict information is an important piece of information to
> > diagnose data divergence and thus should be logged always.
> >
> > Let's wait for others' opinions.
>
> Thanks Shveta for you opinion,
>
> Here is what I propose considering balance between simplicity with
> future scalability:
>
> 1. Retain 'conflict_log_destination' as the option name.
> 2. Current supported values include 'log', 'table', or 'all' (which
> directs output to both locations). But we will not support comma
> separated values in the first version.
If users set conflict_log_destination='table', we don't report
anything related to conflict to the server logs while all other errors
generated by apply workers go to the server logs? or do we write
ERRORs without the conflict details while writing full conflict logs
to the table? If we go with the former idea, monitoring tools would
not be able to catch ERROR logs. Users can set
conflict_log_destination='all' in this case, but they might want to
avoid bloating the server logs by the detailed conflict information. I
wonder if there might be cases where monitoring tools want to detect
at least the fact that errors occur in the system.
Regards,
--
Masahiko Sawada
Amazon Web Services: https://aws.amazon.com
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-20 09:47 vignesh C <vignesh21@gmail.com>
parent: vignesh C <vignesh21@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: vignesh C @ 2025-12-20 09:47 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, 16 Dec 2025 at 09:54, vignesh C <vignesh21@gmail.com> wrote:
>
> On Sun, 14 Dec 2025 at 21:17, Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Sun, Dec 14, 2025 at 3:51 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > On Fri, Dec 12, 2025 at 3:04 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > > >
> > > > On Thu, Dec 11, 2025 at 7:49 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > > >
> > > > > I was considering the interdependence between the subscription and the
> > > > > conflict log table (CLT). IMHO, it would be logical to establish the
> > > > > subscription as dependent on the CLT. This way, if someone attempts to
> > > > > drop the CLT, the system would recognize the dependency of the
> > > > > subscription and prevent the drop unless the subscription is removed
> > > > > first or the CASCADE option is used.
> > > > >
> > > > > However, while investigating this, I encountered an error [1] stating
> > > > > that global objects are not supported in this context. This indicates
> > > > > that global objects cannot be made dependent on local objects.
> > > > >
> > > >
> > > > What we need here is an equivalent of DEPENDENCY_INTERNAL for database
> > > > objects. For example, consider following case:
> > > > postgres=# create table t1(c1 int primary key);
> > > > CREATE TABLE
> > > > postgres=# \d+ t1
> > > > Table "public.t1"
> > > > Column | Type | Collation | Nullable | Default | Storage |
> > > > Compression | Stats target | Description
> > > > --------+---------+-----------+----------+---------+---------+-------------+--------------+-------------
> > > > c1 | integer | | not null | | plain |
> > > > | |
> > > > Indexes:
> > > > "t1_pkey" PRIMARY KEY, btree (c1)
> > > > Publications:
> > > > "pub1"
> > > > Not-null constraints:
> > > > "t1_c1_not_null" NOT NULL "c1"
> > > > Access method: heap
> > > > postgres=# drop index t1_pkey;
> > > > ERROR: cannot drop index t1_pkey because constraint t1_pkey on table
> > > > t1 requires it
> > > > HINT: You can drop constraint t1_pkey on table t1 instead.
> > > >
> > > > Here, the PK index is created as part for CREATE TABLE operation and
> > > > pk_index is not allowed to be dropped independently.
> > > >
> > > > > Although making an object dependent on global/shared objects is
> > > > > possible for certain types of shared objects [2], this is not our main
> > > > > objective.
> > > > >
> > > >
> > > > As per my understanding from the above example, we need something like
> > > > that only for shared object subscription and (internally created)
> > > > table.
> > >
> > > Yeah that seems to be exactly what we want, so I tried doing that by
> > > recording DEPENDENCY_INTERNAL dependency of CLT on subscription[1] and
> > > it is behaving as we want[2]. And while dropping the subscription or
> > > altering CLT we can delete internal dependency so that CLT get dropped
> > > automatically[3]
> > >
> > > I will send an updated patch after testing a few more scenarios and
> > > fixing other pending issues.
> > >
> > > [1]
> > > + ObjectAddressSet(myself, RelationRelationId, relid);
> > > + ObjectAddressSet(subaddr, SubscriptionRelationId, subid);
> > > + recordDependencyOn(&myself, &subaddr, DEPENDENCY_INTERNAL);
> > >
> > >
> > > [2]
> > > postgres[670778]=# DROP TABLE myschema.conflict_log_history2;
> > > ERROR: 2BP01: cannot drop table myschema.conflict_log_history2
> > > because subscription sub requires it
> > > HINT: You can drop subscription sub instead.
> > > LOCATION: findDependentObjects, dependency.c:788
> > > postgres[670778]=#
> > >
> > > [3]
> > > ObjectAddressSet(object, SubscriptionRelationId, subid);
> > > performDeletion(&object, DROP_CASCADE
> > > PERFORM_DELETION_INTERNAL |
> > > PERFORM_DELETION_SKIP_ORIGINAL);
> > >
> > >
> >
> > Here is the patch which implements the dependency and fixes other
> > comments from Shveta.
>
> Thanks for the changes, the new implementation based on dependency
> creates a cycle while dumping:
> ./pg_dump -d postgres -f dump1.txt -p 5433
> pg_dump: warning: could not resolve dependency loop among these items:
> pg_dump: detail: TABLE conflict (ID 225 OID 16397)
> pg_dump: detail: SUBSCRIPTION (ID 3484 OID 16396)
> pg_dump: detail: POST-DATA BOUNDARY (ID 3491)
> pg_dump: detail: TABLE DATA t1 (ID 3485 OID 16384)
> pg_dump: detail: PRE-DATA BOUNDARY (ID 3490)
>
> This can be seen with a simple subscription with conflict_log_table.
> This was working fine with the v11 version patch.
The attached v13 patch includes the fix for this issue. In addition,
it now raises an error when attempting to configure a conflict log
table that belongs to a temporary schema or is not a permanent
(persistent) relation.
Regards,
Vignesh
Attachments:
[text/x-patch] v13-0001-Add-configurable-conflict-log-table-for-Logical-.patch (126.0K, ../../CALDaNm3USsXVNBsfdpkp60HVgrTV4taWMk1xZYNBa7QUF=V0jg@mail.gmail.com/2-v13-0001-Add-configurable-conflict-log-table-for-Logical-.patch)
download | inline diff:
From bb582d90242e32b34fe81d0fe8fc419460c28da9 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 12 Nov 2025 10:43:19 +0530
Subject: [PATCH v13 1/2] Add configurable conflict log table for Logical
Replication
This patch adds a feature to provide a structured, queryable record of all
logical replication conflicts. The current approach of logging conflicts as
plain text in the server logs makes it difficult to query, analyze, and
use for external monitoring and automation.
This patch addresses these limitations by introducing a configurable
conflict_log_table option in the CREATE SUBSCRIPTION command. Key design
decisions include:
User-Managed Table: The conflict log is stored in a user-managed table
rather than a system catalog.
Structured Data: Conflict details, including the original and remote tuples,
are stored in JSON columns, providing a flexible format to accommodate different
table schemas.
Comprehensive Information: The log table captures essential attributes such as
local and remote transaction IDs, LSNs, commit timestamps, and conflict type,
providing a complete record for post-mortem analysis.
This feature will make logical replication conflicts easier to monitor and manage,
significantly improving the overall resilience and operability of replication setups.
The conflict log tables will not be included in a publication, even if the publication
is configured to include ALL TABLES or ALL TABLES IN SCHEMA.
Note: A single remote tuple may conflict with multiple local tuples when conflict type
is CT_MULTIPLE_UNIQUE_CONFLICTS, so for handling this case we create a single row in
conflict log table with respect to each remote conflict tuple even if it conflicts with
multiple local tuples and we store the multiple conflict tuples as a single JSON array
element in format as
[ { "xid": "1001", "commit_ts": "...", "origin": "...", "tuple": {...} }, ... ]
We can extract the elements of the local tuples from the conflict log table row
as given in below example.
SELECT remote_xid, relname, remote_origin, local_conflicts[1] ->> 'xid' AS local_xid,
local_conflicts[1] ->> 'tuple' AS local_tuple
FROM myschema.conflict_log_history2;
remote_xid | relname | remote_origin | local_xid | local_tuple
------------+----------+---------------+-----------+---------------------
760 | test | pg_16406 | 771 | {"a":1,"b":10}
765 | conf_tab | pg_16406 | 775 | {"a":2,"b":2,"c":2}
---
src/backend/catalog/pg_publication.c | 26 +-
src/backend/commands/subscriptioncmds.c | 307 ++++++++++-
src/backend/replication/logical/conflict.c | 569 ++++++++++++++++++++-
src/backend/replication/logical/launcher.c | 1 +
src/backend/replication/logical/worker.c | 37 +-
src/backend/utils/cache/lsyscache.c | 38 ++
src/bin/psql/describe.c | 24 +-
src/bin/psql/tab-complete.in.c | 8 +-
src/include/catalog/pg_subscription.h | 5 +
src/include/commands/subscriptioncmds.h | 2 +
src/include/replication/conflict.h | 32 ++
src/include/replication/worker_internal.h | 7 +
src/include/utils/lsyscache.h | 1 +
src/test/regress/expected/subscription.out | 370 ++++++++++----
src/test/regress/sql/subscription.sql | 114 +++++
15 files changed, 1413 insertions(+), 128 deletions(-)
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index 7aa3f179924..9f84e02b7ef 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -31,6 +31,7 @@
#include "catalog/pg_publication_rel.h"
#include "catalog/pg_type.h"
#include "commands/publicationcmds.h"
+#include "commands/subscriptioncmds.h"
#include "funcapi.h"
#include "utils/array.h"
#include "utils/builtins.h"
@@ -85,6 +86,15 @@ check_publication_add_relation(Relation targetrel)
errmsg("cannot add relation \"%s\" to publication",
RelationGetRelationName(targetrel)),
errdetail("This operation is not supported for unlogged tables.")));
+
+ /* Can't be conflict log table */
+ if (IsConflictLogTable(RelationGetRelid(targetrel)))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot add relation \"%s.%s\" to publication",
+ get_namespace_name(RelationGetNamespace(targetrel)),
+ RelationGetRelationName(targetrel)),
+ errdetail("This operation is not supported for conflict log tables.")));
}
/*
@@ -145,6 +155,13 @@ is_publishable_class(Oid relid, Form_pg_class reltuple)
/*
* Another variant of is_publishable_class(), taking a Relation.
+ *
+ * Note: Conflict log tables are not publishable. However, we intentionally
+ * skip this check here because this function is called for every change and
+ * performing this check during every change publication is costly. To ensure
+ * unpublishable entries are ignored without incurring performance overhead,
+ * tuples inserted into the conflict log table uses the HEAP_INSERT_NO_LOGICAL
+ * flag. This allows the decoding layer to bypass these entries automatically.
*/
bool
is_publishable_relation(Relation rel)
@@ -169,7 +186,10 @@ pg_relation_is_publishable(PG_FUNCTION_ARGS)
tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
if (!HeapTupleIsValid(tuple))
PG_RETURN_NULL();
- result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple));
+
+ /* Subscription conflict log tables are not published */
+ result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple)) &&
+ !IsConflictLogTable(relid);
ReleaseSysCache(tuple);
PG_RETURN_BOOL(result);
}
@@ -890,7 +910,9 @@ GetAllPublicationRelations(char relkind, bool pubviaroot)
Form_pg_class relForm = (Form_pg_class) GETSTRUCT(tuple);
Oid relid = relForm->oid;
+ /* Subscription conflict log tables are not published */
if (is_publishable_class(relid, relForm) &&
+ !IsConflictLogTable(relid) &&
!(relForm->relispartition && pubviaroot))
result = lappend_oid(result, relid);
}
@@ -1018,7 +1040,7 @@ GetSchemaPublicationRelations(Oid schemaid, PublicationPartOpt pub_partopt)
Oid relid = relForm->oid;
char relkind;
- if (!is_publishable_class(relid, relForm))
+ if (!is_publishable_class(relid, relForm) || IsConflictLogTable(relid))
continue;
relkind = get_rel_relkind(relid);
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index abbcaff0838..b044ed70a2a 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -15,16 +15,19 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/htup_details.h"
#include "access/table.h"
#include "access/twophase.h"
#include "access/xact.h"
#include "catalog/catalog.h"
#include "catalog/dependency.h"
+#include "catalog/heap.h"
#include "catalog/indexing.h"
#include "catalog/namespace.h"
#include "catalog/objectaccess.h"
#include "catalog/objectaddress.h"
+#include "catalog/pg_am_d.h"
#include "catalog/pg_authid_d.h"
#include "catalog/pg_database_d.h"
#include "catalog/pg_subscription.h"
@@ -34,6 +37,7 @@
#include "commands/event_trigger.h"
#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "miscadmin.h"
#include "nodes/makefuncs.h"
#include "pgstat.h"
@@ -51,6 +55,7 @@
#include "utils/lsyscache.h"
#include "utils/memutils.h"
#include "utils/pg_lsn.h"
+#include "utils/regproc.h"
#include "utils/syscache.h"
/*
@@ -75,6 +80,7 @@
#define SUBOPT_MAX_RETENTION_DURATION 0x00008000
#define SUBOPT_LSN 0x00010000
#define SUBOPT_ORIGIN 0x00020000
+#define SUBOPT_CONFLICT_LOG_TABLE 0x00040000
/* check if the 'val' has 'bits' set */
#define IsSet(val, bits) (((val) & (bits)) == (bits))
@@ -103,6 +109,7 @@ typedef struct SubOpts
bool retaindeadtuples;
int32 maxretention;
char *origin;
+ char *conflictlogtable;
XLogRecPtr lsn;
} SubOpts;
@@ -135,7 +142,8 @@ static List *merge_publications(List *oldpublist, List *newpublist, bool addpub,
static void ReportSlotConnectionError(List *rstates, Oid subid, char *slotname, char *err);
static void CheckAlterSubOption(Subscription *sub, const char *option,
bool slot_needs_update, bool isTopLevel);
-
+static void create_conflict_log_table(Oid namespaceId, char *conflictrel,
+ Oid subid);
/*
* Common option parsing function for CREATE and ALTER SUBSCRIPTION commands.
@@ -191,6 +199,8 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->maxretention = 0;
if (IsSet(supported_opts, SUBOPT_ORIGIN))
opts->origin = pstrdup(LOGICALREP_ORIGIN_ANY);
+ if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_TABLE))
+ opts->conflictlogtable = NULL;
/* Parse options */
foreach(lc, stmt_options)
@@ -402,6 +412,19 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->specified_opts |= SUBOPT_LSN;
opts->lsn = lsn;
}
+ else if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_TABLE) &&
+ strcmp(defel->defname, "conflict_log_table") == 0)
+ {
+ if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_LOG_TABLE))
+ errorConflictingDefElem(defel, pstate);
+
+ opts->specified_opts |= SUBOPT_CONFLICT_LOG_TABLE;
+ opts->conflictlogtable = defGetString(defel);
+
+ /* Setting conflict_log_table = NONE is treated as no table. */
+ if (strcmp(opts->conflictlogtable, "none") == 0)
+ opts->conflictlogtable = NULL;
+ }
else
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
@@ -599,6 +622,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bits32 supported_opts;
SubOpts opts = {0};
AclResult aclresult;
+ Oid conflictlogtable_nspid = InvalidOid;
+ char *conflictlogtable = NULL;
/*
* Parse and check options.
@@ -612,7 +637,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
SUBOPT_DISABLE_ON_ERR | SUBOPT_PASSWORD_REQUIRED |
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
- SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN);
+ SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_TABLE);
parse_subscription_options(pstate, stmt->options, supported_opts, &opts);
/*
@@ -747,6 +773,34 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
values[Anum_pg_subscription_suborigin - 1] =
CStringGetTextDatum(opts.origin);
+ /*
+ * If a conflict log table name is specified, parse the schema and table
+ * name from the string. Store the namespace OID and the table name in
+ * the pg_subscription catalog tuple.
+ */
+ if (opts.conflictlogtable)
+ {
+ List *names;
+
+ /* Explicitly check for empty string before any processing. */
+ if (opts.conflictlogtable[0] == '\0')
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("conflict log table name cannot be empty"),
+ errhint("Provide a valid table name or omit the parameter.")));
+
+ names = stringToQualifiedNameList(opts.conflictlogtable, NULL);
+
+ conflictlogtable_nspid =
+ QualifiedNameGetCreationNamespace(names, &conflictlogtable);
+ values[Anum_pg_subscription_subconflictlognspid - 1] =
+ ObjectIdGetDatum(conflictlogtable_nspid);
+ values[Anum_pg_subscription_subconflictlogtable - 1] =
+ CStringGetTextDatum(conflictlogtable);
+ }
+ else
+ nulls[Anum_pg_subscription_subconflictlogtable - 1] = true;
+
tup = heap_form_tuple(RelationGetDescr(rel), values, nulls);
/* Insert tuple into catalog. */
@@ -768,6 +822,11 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
ReplicationOriginNameForLogicalRep(subid, InvalidOid, originname, sizeof(originname));
replorigin_create(originname);
+ /* If a conflict log table name is given then create the table. */
+ if (opts.conflictlogtable)
+ create_conflict_log_table(conflictlogtable_nspid, conflictlogtable,
+ subid);
+
/*
* Connect to remote side to execute requested commands and fetch table
* and sequence info.
@@ -1410,7 +1469,8 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
SUBOPT_MAX_RETENTION_DURATION |
- SUBOPT_ORIGIN);
+ SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_TABLE);
parse_subscription_options(pstate, stmt->options,
supported_opts, &opts);
@@ -1665,6 +1725,96 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
origin = opts.origin;
}
+ if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_LOG_TABLE))
+ {
+ Oid nspid = InvalidOid;
+ Oid old_nspid = InvalidOid;
+ char *old_relname = NULL;
+ char *relname = NULL;
+ List *names = NIL;
+
+ if (opts.conflictlogtable != NULL)
+ {
+ /* Explicitly check for empty string before any processing. */
+ if (opts.conflictlogtable[0] == '\0')
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("conflict log table name cannot be empty"),
+ errhint("Provide a valid table name or omit the parameter.")));
+
+ names = stringToQualifiedNameList(opts.conflictlogtable,
+ NULL);
+ nspid = QualifiedNameGetCreationNamespace(names, &relname);
+ }
+
+ /* Fetch the existing conflict table information. */
+ old_relname =
+ get_subscription_conflict_log_table(subid, &old_nspid);
+
+ /*
+ * If the subscription already uses this conflict log table
+ * and it exists, just issue a notice.
+ */
+ if (old_relname != NULL && relname != NULL
+ && (strcmp(old_relname, relname) == 0) &&
+ old_nspid == nspid &&
+ OidIsValid(get_relname_relid(old_relname, old_nspid)))
+ {
+ char *nspname = get_namespace_name(nspid);
+
+ ereport(NOTICE,
+ (errmsg("\"%s.%s\" is already in use as the conflict log table for this subscription",
+ nspname, relname)));
+ pfree(nspname);
+ }
+ else
+ {
+ ObjectAddress object;
+
+ /*
+ * Conflict log tables are recorded as internal
+ * dependencies of the subscription. Before
+ * associating a new table, drop the existing table to
+ * avoid stale or orphaned relations.
+ *
+ * XXX: At present, only conflict log tables are
+ * managed this way. In future if we introduce
+ * additional internal dependencies, we may need
+ * a targeted deletion to avoid deletion of any
+ * other objects.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId, subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
+ /*
+ * Need to create a new table if a new name was
+ * provided.
+ */
+ if (relname != NULL)
+ create_conflict_log_table(nspid, relname, subid);
+
+ values[Anum_pg_subscription_subconflictlognspid - 1] =
+ ObjectIdGetDatum(nspid);
+
+ if (relname != NULL)
+ values[Anum_pg_subscription_subconflictlogtable - 1] =
+ CStringGetTextDatum(relname);
+ else
+ nulls[Anum_pg_subscription_subconflictlogtable - 1] =
+ true;
+
+ replaces[Anum_pg_subscription_subconflictlognspid - 1] =
+ true;
+ replaces[Anum_pg_subscription_subconflictlogtable - 1] =
+ true;
+ }
+
+ if (old_relname != NULL)
+ pfree(old_relname);
+ }
+
update_tuple = true;
break;
}
@@ -2027,6 +2177,7 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
Form_pg_subscription form;
List *rstates;
bool must_use_password;
+ ObjectAddress object;
/*
* The launcher may concurrently start a new worker for this subscription.
@@ -2184,6 +2335,19 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
/* Clean up dependencies */
deleteSharedDependencyRecordsFor(SubscriptionRelationId, subid, 0);
+ /*
+ * Conflict log tables are recorded as internal dependencies of the
+ * subscription. We must drop the dependent objects before the
+ * subscription itself is removed. By using
+ * PERFORM_DELETION_SKIP_ORIGINAL, we ensure that only the conflict log
+ * table is reaped while the subscription remains for the final deletion
+ * step.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId, subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
/* Remove any associated relation synchronization states. */
RemoveSubscriptionRel(subid, InvalidOid);
@@ -3188,3 +3352,140 @@ defGetStreamingMode(DefElem *def)
def->defname)));
return LOGICALREP_STREAM_OFF; /* keep compiler quiet */
}
+
+/*
+ * Builds the TupleDesc for the conflict log table.
+ */
+static TupleDesc
+create_conflict_log_table_tupdesc(void)
+{
+ TupleDesc tupdesc;
+ int i;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_CONFLICT_ATTR_NUM);
+
+ for (i = 0; i < MAX_CONFLICT_ATTR_NUM; i++)
+ {
+ Oid type_oid = ConflictLogSchema[i].atttypid;
+
+ /* Special handling for the JSON array type for proper TupleDescInitEntry call */
+ if (type_oid == JSONARRAYOID)
+ type_oid = get_array_type(JSONOID);
+
+ TupleDescInitEntry(tupdesc, i + 1,
+ ConflictLogSchema[i].attname,
+ type_oid,
+ -1, 0);
+ }
+
+ return BlessTupleDesc(tupdesc);
+}
+
+/*
+ * Create conflict log table.
+ *
+ * The subscription owner becomes the owner of this table and has all
+ * privileges on it.
+ */
+static void
+create_conflict_log_table(Oid namespaceId, char *conflictrel, Oid subid)
+{
+ TupleDesc tupdesc;
+ Oid relid;
+ ObjectAddress myself;
+ ObjectAddress subaddr;
+
+ /* Report an error if the specified conflict log table already exists. */
+ if (OidIsValid(get_relname_relid(conflictrel, namespaceId)))
+ ereport(ERROR,
+ (errcode(ERRCODE_DUPLICATE_TABLE),
+ errmsg("cannot create conflict log table \"%s.%s\" because a table with that name already exists",
+ get_namespace_name(namespaceId), conflictrel),
+ errhint("Use a different name for the conflict log table or drop the existing table.")));
+
+ /*
+ * Conflict log tables must be permanent relations. Disallow creation in
+ * temporary namespaces to ensure the same.
+ */
+ if (isTempNamespace(namespaceId))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot create conflict log table \"%s\" in a temporary namespace",
+ conflictrel),
+ errhint("Use a permanent schema.")));
+
+ /* Build the tuple descriptor for the new table. */
+ tupdesc = create_conflict_log_table_tupdesc();
+
+ /* Create conflict log table. */
+ relid = heap_create_with_catalog(conflictrel,
+ namespaceId,
+ 0,
+ InvalidOid,
+ InvalidOid,
+ InvalidOid,
+ GetUserId(),
+ HEAP_TABLE_AM_OID,
+ tupdesc,
+ NIL,
+ RELKIND_RELATION,
+ RELPERSISTENCE_PERMANENT,
+ false,
+ false,
+ ONCOMMIT_NOOP,
+ (Datum) 0,
+ false,
+ false,
+ false,
+ InvalidOid,
+ NULL);
+
+ /*
+ * Establish an internal dependency between the conflict log table and
+ * the subscription. By using DEPENDENCY_INTERNAL, we ensure the table
+ * is automatically reaped when the subscription is dropped. This also
+ * prevents the table from being dropped independently unless the
+ * subscription itself is removed.
+ */
+ ObjectAddressSet(myself, RelationRelationId, relid);
+ ObjectAddressSet(subaddr, SubscriptionRelationId, subid);
+ recordDependencyOn(&myself, &subaddr, DEPENDENCY_INTERNAL);
+
+ /* Release tuple descriptor memory. */
+ FreeTupleDesc(tupdesc);
+}
+
+/*
+ * Check if the specified relation is used as a conflict log table by any
+ * subscription.
+ */
+bool
+IsConflictLogTable(Oid relid)
+{
+ Relation rel;
+ TableScanDesc scan;
+ HeapTuple tup;
+ bool is_clt = false;
+
+ rel = table_open(SubscriptionRelationId, AccessShareLock);
+ scan = table_beginscan_catalog(rel, 0, NULL);
+
+ while (HeapTupleIsValid(tup = heap_getnext(scan, ForwardScanDirection)))
+ {
+ Form_pg_subscription subform = (Form_pg_subscription) GETSTRUCT(tup);
+ Oid nspid;
+ char *relname;
+
+ relname = get_subscription_conflict_log_table(subform->oid, &nspid);
+ if (relname && relid == get_relname_relid(relname, nspid))
+ {
+ is_clt = true;
+ break;
+ }
+ }
+
+ table_endscan(scan);
+ table_close(rel, AccessShareLock);
+
+ return is_clt;
+}
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 16695592265..1d357805eca 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -15,13 +15,21 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/tableam.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "pgstat.h"
#include "replication/conflict.h"
#include "replication/worker_internal.h"
#include "storage/lmgr.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/lsyscache.h"
+#include "utils/pg_lsn.h"
+#include "utils/jsonb.h"
+
+#define MAX_LOCAL_CONFLICT_INFO_ATTRS 5
static const char *const ConflictTypeNames[] = {
[CT_INSERT_EXISTS] = "insert_exists",
@@ -50,8 +58,27 @@ static char *build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *localslot,
TupleTableSlot *remoteslot,
Oid indexoid);
+static void build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull);
static char *build_index_value_desc(EState *estate, Relation localrel,
TupleTableSlot *slot, Oid indexoid);
+static Datum tuple_table_slot_to_json_datum(TupleTableSlot *slot);
+static Datum tuple_table_slot_to_indextup_json(EState *estate,
+ Relation localrel,
+ Oid replica_index,
+ TupleTableSlot *slot);
+static TupleDesc build_conflict_tupledesc(void);
+static Datum build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples);
+static void prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot);
/*
* Get the xmin and commit timestamp data (origin and timestamp) associated
@@ -106,6 +133,7 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
TupleTableSlot *remoteslot, List *conflicttuples)
{
Relation localrel = relinfo->ri_RelationDesc;
+ Relation conflictlogrel = GetConflictLogTableRel();
StringInfoData err_detail;
initStringInfo(&err_detail);
@@ -120,6 +148,37 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
conflicttuple->ts,
&err_detail);
+ /* Insert conflict details to conflict log table. */
+ if (conflictlogrel)
+ {
+ if (ValidateConflictLogTable(conflictlogrel))
+ {
+ /*
+ * Prepare the conflict log tuple. If the error level is below
+ * ERROR, insert it immediately. Otherwise, defer the insertion to
+ * a new transaction after the current one aborts, ensuring the
+ * insertion of the log tuple is not rolled back.
+ */
+ prepare_conflict_log_tuple(estate,
+ relinfo->ri_RelationDesc,
+ conflictlogrel,
+ type,
+ searchslot,
+ conflicttuples,
+ remoteslot);
+ if (elevel < ERROR)
+ InsertConflictLogTuple(conflictlogrel);
+ }
+ else
+ ereport(WARNING,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("Conflict log table \"%s.%s\" structure changed, skipping insertion",
+ get_namespace_name(RelationGetNamespace(conflictlogrel)),
+ RelationGetRelationName(conflictlogrel)));
+
+ table_close(conflictlogrel, RowExclusiveLock);
+ }
+
pgstat_report_subscription_conflict(MySubscription->oid, type);
ereport(elevel,
@@ -162,6 +221,141 @@ InitConflictIndexes(ResultRelInfo *relInfo)
relInfo->ri_onConflictArbiterIndexes = uniqueIndexes;
}
+/*
+ * GetConflictLogTableRel
+ *
+ * Get the information of the specific conflict log table defined in
+ * pg_subscription and opens the relation for insertion. The caller is
+ * responsible for closing the returned relation handle.
+ */
+Relation
+GetConflictLogTableRel(void)
+{
+ Oid nspid;
+ Oid conflictlogrelid;
+ Relation conflictlogrel = NULL;
+ char *conflictlogtable;
+
+ /* If conflict log table is not set for the subscription just return. */
+ conflictlogtable = get_subscription_conflict_log_table(
+ MyLogicalRepWorker->subid, &nspid);
+ if (conflictlogtable == NULL)
+ return NULL;
+
+ conflictlogrelid = get_relname_relid(conflictlogtable, nspid);
+ if (OidIsValid(conflictlogrelid))
+ conflictlogrel = table_open(conflictlogrelid, RowExclusiveLock);
+
+ /* Conflict log table is dropped or not accessible. */
+ if (conflictlogrel == NULL)
+ ereport(WARNING,
+ (errcode(ERRCODE_UNDEFINED_TABLE),
+ errmsg("conflict log table \"%s.%s\" does not exist",
+ get_namespace_name(nspid), conflictlogtable)));
+
+ pfree(conflictlogtable);
+
+ return conflictlogrel;
+}
+
+/*
+ * InsertConflictLogTuple
+ *
+ * Insert conflict log tuple into the conflict log table. It uses
+ * HEAP_INSERT_NO_LOGICAL to explicitly block logical decoding
+ * of the tuple inserted into the conflict log table.
+ */
+void
+InsertConflictLogTuple(Relation conflictlogrel)
+{
+ int options = HEAP_INSERT_NO_LOGICAL;
+
+ /* A valid tuple must be prepared and stored in MyLogicalRepWorker. */
+ Assert(MyLogicalRepWorker->conflict_log_tuple != NULL);
+
+ heap_insert(conflictlogrel, MyLogicalRepWorker->conflict_log_tuple,
+ GetCurrentCommandId(true), options, NULL);
+
+ /* Free conflict log tuple. */
+ heap_freetuple(MyLogicalRepWorker->conflict_log_tuple);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+}
+
+/*
+ * ValidateConflictLogTable - Validate conflict log table
+ *
+ * Validate whether the conflict log table is still suitable for considering as
+ * conflict log table.
+ */
+bool
+ValidateConflictLogTable(Relation rel)
+{
+ Relation pg_attribute;
+ HeapTuple atup;
+ ScanKeyData scankey;
+ SysScanDesc scan;
+ Form_pg_attribute attForm;
+ int attcnt = 0;
+ bool tbl_ok = true;
+
+ /*
+ * Check whether the table definition including its column names, data
+ * types, and column ordering meets the requirements for conflict log
+ * table.
+ */
+ pg_attribute = table_open(AttributeRelationId, AccessShareLock);
+ ScanKeyInit(&scankey,
+ Anum_pg_attribute_attrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationGetRelid(rel)));
+
+ scan = systable_beginscan(pg_attribute, AttributeRelidNumIndexId, true,
+ SnapshotSelf, 1, &scankey);
+
+ /* We only need to check up to MAX_CONFLICT_ATTR_NUM attributes */
+ while (HeapTupleIsValid(atup = systable_getnext(scan)))
+ {
+ const ConflictLogColumnDef *expected;
+ int schema_idx;
+
+ attForm = (Form_pg_attribute) GETSTRUCT(atup);
+
+ /* Skip system columns and dropped columns */
+ if (attForm->attnum < 1 || attForm->attisdropped)
+ continue;
+
+ attcnt++;
+
+ /* attnum 1 corresponds to index 0 in ConflictLogSchema */
+ schema_idx = attForm->attnum - 1;
+
+ /* Check against the central schema definition */
+ if (schema_idx >= MAX_CONFLICT_ATTR_NUM)
+ {
+ /* Found an extra column beyond the required set */
+ tbl_ok = false;
+ break;
+ }
+
+ expected = &ConflictLogSchema[schema_idx];
+
+ if (attForm->atttypid != expected->atttypid ||
+ strcmp(NameStr(attForm->attname), expected->attname) != 0)
+ {
+ tbl_ok = false;
+ break;
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_attribute, AccessShareLock);
+
+ if (attcnt != MAX_CONFLICT_ATTR_NUM || !tbl_ok)
+ return false;
+
+ return true;
+}
+
/*
* Add SQLSTATE error code to the current conflict report.
*/
@@ -472,6 +666,40 @@ build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
return tuple_value.data;
}
+/*
+ * Helper function to extract the "raw" index key Datums and their null flags
+ * from a TupleTableSlot, given an already open index descriptor.
+ * This is the reusable core logic.
+ */
+static void
+build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull)
+{
+ TupleTableSlot *tableslot = slot;
+
+ /*
+ * If the slot is a virtual slot, copy it into a heap tuple slot as
+ * FormIndexDatum only works with heap tuple slots.
+ */
+ if (TTS_IS_VIRTUAL(slot))
+ {
+ /* Slot is created within the EState's tuple table */
+ tableslot = table_slot_create(localrel, &estate->es_tupleTable);
+ tableslot = ExecCopySlot(tableslot, slot);
+ }
+
+ /*
+ * Initialize ecxt_scantuple for potential use in FormIndexDatum
+ */
+ GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+
+ /* Form the index datums */
+ FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values,
+ isnull);
+}
+
/*
* Helper functions to construct a string describing the contents of an index
* entry. See BuildIndexValueDescription for details.
@@ -487,41 +715,336 @@ build_index_value_desc(EState *estate, Relation localrel, TupleTableSlot *slot,
Relation indexDesc;
Datum values[INDEX_MAX_KEYS];
bool isnull[INDEX_MAX_KEYS];
- TupleTableSlot *tableslot = slot;
- if (!tableslot)
+ if (!slot)
return NULL;
Assert(CheckRelationOidLockedByMe(indexoid, RowExclusiveLock, true));
indexDesc = index_open(indexoid, NoLock);
- /*
- * If the slot is a virtual slot, copy it into a heap tuple slot as
- * FormIndexDatum only works with heap tuple slots.
- */
- if (TTS_IS_VIRTUAL(slot))
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+
+ index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+
+ index_close(indexDesc, NoLock);
+
+ return index_value;
+}
+
+/*
+ * tuple_table_slot_to_json_datum
+ *
+ * Helper function to convert a TupleTableSlot to Jsonb.
+ */
+static Datum
+tuple_table_slot_to_json_datum(TupleTableSlot *slot)
+{
+ HeapTuple tuple;
+ Datum datum;
+ Datum json;
+
+ Assert(slot != NULL);
+
+ tuple = ExecCopySlotHeapTuple(slot);
+ datum = heap_copy_tuple_as_datum(tuple, slot->tts_tupleDescriptor);
+
+ json = DirectFunctionCall1(row_to_json, datum);
+ heap_freetuple(tuple);
+
+ return json;
+}
+
+/*
+ * tuple_table_slot_to_indextup_json
+ *
+ * Fetch replica identity key from the tuple table slot and convert into a
+ * jsonb datum.
+ */
+static Datum
+tuple_table_slot_to_indextup_json(EState *estate, Relation localrel,
+ Oid indexid, TupleTableSlot *slot)
+{
+ Relation indexDesc;
+ Datum values[INDEX_MAX_KEYS];
+ bool isnull[INDEX_MAX_KEYS];
+ HeapTuple tuple;
+ TupleDesc tupdesc;
+ Datum datum;
+
+ Assert(slot != NULL);
+
+ Assert(CheckRelationOidLockedByMe(indexid, RowExclusiveLock, true));
+
+ indexDesc = index_open(indexid, NoLock);
+
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+ tupdesc = RelationGetDescr(indexDesc);
+
+ /* Bless the tupdesc so it can be looked up by row_to_json. */
+ BlessTupleDesc(tupdesc);
+
+ /* Form the replica identity tuple. */
+ tuple = heap_form_tuple(tupdesc, values, isnull);
+ datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ index_close(indexDesc, NoLock);
+
+ /* Convert to a JSONB datum. */
+ return DirectFunctionCall1(row_to_json, datum);
+}
+
+/*
+ * Initialize the tuple descriptor for local conflict info.
+ */
+static TupleDesc
+build_conflict_tupledesc(void)
+{
+ TupleDesc tupdesc;
+ int attno = 1;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ TupleDescInitEntry(tupdesc, (AttrNumber) attno++, "xid",
+ XIDOID, -1, 0);
+ TupleDescInitEntry(tupdesc, (AttrNumber) attno++, "commit_ts",
+ TIMESTAMPTZOID, -1, 0);
+ TupleDescInitEntry(tupdesc, (AttrNumber) attno++, "origin",
+ TEXTOID, -1, 0);
+ TupleDescInitEntry(tupdesc, (AttrNumber) attno++, "key",
+ JSONOID, -1, 0);
+ TupleDescInitEntry(tupdesc, (AttrNumber) attno, "tuple",
+ JSONOID, -1, 0);
+
+ BlessTupleDesc(tupdesc);
+
+ Assert(attno == MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ return tupdesc;
+}
+
+/*
+ * Builds the local conflicts JSONB array column from the list of
+ * ConflictTupleInfo objects.
+ *
+ * Example output structure:
+ * [ { "xid": "1001", "commit_ts": "...", "origin": "...", "tuple": {...} }, ... ]
+ */
+static Datum
+build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples)
+{
+ ListCell *lc;
+ List *json_datums = NIL; /* List to hold the row_to_json results (type json) */
+ Datum *json_datum_array;
+ bool *json_null_array;
+ Datum json_array_datum;
+ int num_conflicts;
+ int i;
+ int16 typlen;
+ bool typbyval;
+ char typalign;
+ TupleDesc tupdesc;
+
+ /* Build local conflicts tuple descriptor. */
+ tupdesc = build_conflict_tupledesc();
+
+ /* Process local conflict tuple list and prepare an array of JSON. */
+ foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
{
- tableslot = table_slot_create(localrel, &estate->es_tupleTable);
- tableslot = ExecCopySlot(tableslot, slot);
+ Datum values[MAX_LOCAL_CONFLICT_INFO_ATTRS];
+ bool nulls[MAX_LOCAL_CONFLICT_INFO_ATTRS];
+ char *origin_name = NULL;
+ HeapTuple tuple;
+ Datum json_datum;
+ int attno;
+
+ memset(values, 0, sizeof(Datum) * MAX_LOCAL_CONFLICT_INFO_ATTRS);
+ memset(nulls, 0, sizeof(bool) * MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ attno = 0;
+ values[attno++] = TransactionIdGetDatum(conflicttuple->xmin);
+
+ if (conflicttuple->ts)
+ values[attno++] = TimestampTzGetDatum(conflicttuple->ts);
+ else
+ nulls[attno++] = true;
+
+ if (conflicttuple->origin != InvalidRepOriginId)
+ replorigin_by_oid(conflicttuple->origin, true, &origin_name);
+
+ /* Store empty string if origin name for the tuple is NULL. */
+ if (origin_name != NULL)
+ values[attno++] = CStringGetTextDatum(origin_name);
+ else
+ nulls[attno++] = true;
+
+ /*
+ * Add the conflicting key values in the case of a unique constraint
+ * violation.
+ */
+ if (conflict_type == CT_INSERT_EXISTS ||
+ conflict_type == CT_UPDATE_EXISTS ||
+ conflict_type == CT_MULTIPLE_UNIQUE_CONFLICTS)
+ {
+ Oid indexoid = conflicttuple->indexoid;
+
+ Assert(OidIsValid(indexoid) && conflicttuple->slot &&
+ CheckRelationOidLockedByMe(indexoid, RowExclusiveLock,
+ true));
+ values[attno++] =
+ tuple_table_slot_to_indextup_json(estate, rel,
+ indexoid,
+ conflicttuple->slot);
+ }
+ else
+ nulls[attno++] = true;
+
+ /* Convert conflicting tuple to JSON datum. */
+ if (conflicttuple->slot)
+ values[attno] = tuple_table_slot_to_json_datum(conflicttuple->slot);
+ else
+ nulls[attno] = true;
+
+ Assert(attno + 1 == MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ tuple = heap_form_tuple(tupdesc, values, nulls);
+
+ json_datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ /*
+ * Build the higher level JSON datum in format described in function
+ * header.
+ */
+ json_datum = DirectFunctionCall1(row_to_json, json_datum);
+
+ /* Done with the temporary tuple. */
+ heap_freetuple(tuple);
+
+ /* Add to the array element. */
+ json_datums = lappend(json_datums, (void *) json_datum);
}
- /*
- * Initialize ecxt_scantuple for potential use in FormIndexDatum when
- * index expressions are present.
- */
- GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+ num_conflicts = list_length(json_datums);
- /*
- * The values/nulls arrays passed to BuildIndexValueDescription should be
- * the results of FormIndexDatum, which are the "raw" input to the index
- * AM.
- */
- FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values, isnull);
+ json_datum_array = (Datum *) palloc(num_conflicts * sizeof(Datum));
+ json_null_array = (bool *) palloc0(num_conflicts * sizeof(bool));
- index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+ i = 0;
+ foreach(lc, json_datums)
+ {
+ json_datum_array[i] = (Datum) lfirst(lc);
+ i++;
+ }
- index_close(indexDesc, NoLock);
+ /* Construct the json[] array Datum. */
+ get_typlenbyvalalign(JSONOID, &typlen, &typbyval, &typalign);
+ json_array_datum = PointerGetDatum(construct_array(json_datum_array,
+ num_conflicts,
+ JSONOID,
+ typlen,
+ typbyval,
+ typalign));
+ pfree(json_datum_array);
+ pfree(json_null_array);
+
+ return json_array_datum;
+}
- return index_value;
+/*
+ * prepare_conflict_log_tuple
+ *
+ * This routine prepares a tuple detailing a conflict encountered during
+ * logical replication. The prepared tuple will be stored in
+ * MyLogicalRepWorker->conflict_log_tuple which should be inserted into the
+ * conflict log table by calling InsertConflictLogTuple.
+ */
+static void
+prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot)
+{
+ Datum values[MAX_CONFLICT_ATTR_NUM];
+ bool nulls[MAX_CONFLICT_ATTR_NUM];
+ int attno;
+ char *remote_origin = NULL;
+ MemoryContext oldctx;
+
+ Assert(MyLogicalRepWorker->conflict_log_tuple == NULL);
+
+ /* Initialize values and nulls arrays. */
+ memset(values, 0, sizeof(Datum) * MAX_CONFLICT_ATTR_NUM);
+ memset(nulls, 0, sizeof(bool) * MAX_CONFLICT_ATTR_NUM);
+
+ /* Populate the values and nulls arrays. */
+ attno = 0;
+ values[attno++] = ObjectIdGetDatum(RelationGetRelid(rel));
+
+ values[attno++] =
+ CStringGetTextDatum(get_namespace_name(RelationGetNamespace(rel)));
+
+ values[attno++] = CStringGetTextDatum(RelationGetRelationName(rel));
+
+ values[attno++] = CStringGetTextDatum(ConflictTypeNames[conflict_type]);
+
+ if (TransactionIdIsValid(remote_xid))
+ values[attno++] = TransactionIdGetDatum(remote_xid);
+ else
+ nulls[attno++] = true;
+
+ values[attno++] = LSNGetDatum(remote_final_lsn);
+
+ if (remote_commit_ts > 0)
+ values[attno++] = TimestampTzGetDatum(remote_commit_ts);
+ else
+ nulls[attno++] = true;
+
+ if (replorigin_session_origin != InvalidRepOriginId)
+ replorigin_by_oid(replorigin_session_origin, true, &remote_origin);
+
+ if (remote_origin != NULL)
+ values[attno++] = CStringGetTextDatum(remote_origin);
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(searchslot))
+ {
+ Oid replica_index = GetRelationIdentityOrPK(rel);
+
+ /*
+ * If the table has a valid replica identity index, build the index
+ * json datum from key value. Otherwise, construct it from the complete
+ * tuple in REPLICA IDENTITY FULL cases.
+ */
+ if (OidIsValid(replica_index))
+ values[attno++] = tuple_table_slot_to_indextup_json(estate, rel,
+ replica_index,
+ searchslot);
+ else
+ values[attno++] = tuple_table_slot_to_json_datum(searchslot);
+ }
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(remoteslot))
+ values[attno++] = tuple_table_slot_to_json_datum(remoteslot);
+ else
+ nulls[attno++] = true;
+
+ values[attno] = build_local_conflicts_json_array(estate, rel,
+ conflict_type,
+ conflicttuples);
+
+ Assert(attno + 1 == MAX_CONFLICT_ATTR_NUM);
+
+ oldctx = MemoryContextSwitchTo(ApplyContext);
+ MyLogicalRepWorker->conflict_log_tuple =
+ heap_form_tuple(RelationGetDescr(conflictlogrel), values, nulls);
+ MemoryContextSwitchTo(oldctx);
}
diff --git a/src/backend/replication/logical/launcher.c b/src/backend/replication/logical/launcher.c
index 3991e1495d4..bc7e1d9ebde 100644
--- a/src/backend/replication/logical/launcher.c
+++ b/src/backend/replication/logical/launcher.c
@@ -477,6 +477,7 @@ retry:
worker->oldest_nonremovable_xid = retain_dead_tuples
? MyReplicationSlot->data.xmin
: InvalidTransactionId;
+ worker->conflict_log_tuple = NULL;
worker->last_lsn = InvalidXLogRecPtr;
TIMESTAMP_NOBEGIN(worker->last_send_time);
TIMESTAMP_NOBEGIN(worker->last_recv_time);
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index fc64476a9ef..e8f7ab3d5d6 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -482,7 +482,9 @@ static bool MySubscriptionValid = false;
static List *on_commit_wakeup_workers_subids = NIL;
bool in_remote_transaction = false;
-static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+TransactionId remote_xid = InvalidTransactionId;
+TimestampTz remote_commit_ts = 0;
/* fields valid only when processing streamed transaction */
static bool in_streamed_transaction = false;
@@ -1219,6 +1221,8 @@ apply_handle_begin(StringInfo s)
set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
remote_final_lsn = begin_data.final_lsn;
+ remote_commit_ts = begin_data.committime;
+ remote_xid = begin_data.xid;
maybe_start_skipping_changes(begin_data.final_lsn);
@@ -1745,6 +1749,10 @@ apply_handle_stream_start(StringInfo s)
/* extract XID of the top-level transaction */
stream_xid = logicalrep_read_stream_start(s, &first_segment);
+ remote_xid = stream_xid;
+ remote_final_lsn = InvalidXLogRecPtr;
+ remote_commit_ts = 0;
+
if (!TransactionIdIsValid(stream_xid))
ereport(ERROR,
(errcode(ERRCODE_PROTOCOL_VIOLATION),
@@ -5609,6 +5617,33 @@ start_apply(XLogRecPtr origin_startpos)
pgstat_report_subscription_error(MySubscription->oid,
MyLogicalRepWorker->type);
+ /*
+ * Insert any pending conflict log tuple under a new transaction.
+ */
+ if (MyLogicalRepWorker->conflict_log_tuple != NULL)
+ {
+ Relation conflictlogrel;
+
+ StartTransactionCommand();
+ PushActiveSnapshot(GetTransactionSnapshot());
+
+ /* Open conflict log table and insert the tuple. */
+ conflictlogrel = GetConflictLogTableRel();
+ if (ValidateConflictLogTable(conflictlogrel))
+ InsertConflictLogTuple(conflictlogrel);
+ else
+ ereport(WARNING,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("Conflict log table \"%s.%s\" structure changed, skipping insertion",
+ get_namespace_name(RelationGetNamespace(conflictlogrel)),
+ RelationGetRelationName(conflictlogrel)));
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+ table_close(conflictlogrel, RowExclusiveLock);
+
+ PopActiveSnapshot();
+ CommitTransactionCommand();
+ }
+
PG_RE_THROW();
}
}
diff --git a/src/backend/utils/cache/lsyscache.c b/src/backend/utils/cache/lsyscache.c
index 5aa7a26d95c..b9090f7d17d 100644
--- a/src/backend/utils/cache/lsyscache.c
+++ b/src/backend/utils/cache/lsyscache.c
@@ -3879,3 +3879,41 @@ get_subscription_name(Oid subid, bool missing_ok)
return subname;
}
+
+/*
+ * get_subscription_conflict_log_table
+ *
+ * Get conflict log table name and namespace id from subscription.
+ */
+char *
+get_subscription_conflict_log_table(Oid subid, Oid *nspid)
+{
+ HeapTuple tup;
+ Datum datum;
+ bool isnull;
+ char *relname = NULL;
+ Form_pg_subscription subform;
+
+ *nspid = InvalidOid;
+
+ tup = SearchSysCache1(SUBSCRIPTIONOID, ObjectIdGetDatum(subid));
+
+ if (!HeapTupleIsValid(tup))
+ elog(ERROR, "cache lookup failed for subscription %u", subid);
+
+ subform = (Form_pg_subscription) GETSTRUCT(tup);
+
+ /* Get conflict log table name. */
+ datum = SysCacheGetAttr(SUBSCRIPTIONOID,
+ tup,
+ Anum_pg_subscription_subconflictlogtable,
+ &isnull);
+ if (!isnull)
+ {
+ *nspid = subform->subconflictlognspid;
+ relname = pstrdup(TextDatumGetCString(datum));
+ }
+
+ ReleaseSysCache(tup);
+ return relname;
+}
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index 36f24502842..906167fe466 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -6806,7 +6806,7 @@ describeSubscriptions(const char *pattern, bool verbose)
printQueryOpt myopt = pset.popt;
static const bool translate_columns[] = {false, false, false, false,
false, false, false, false, false, false, false, false, false, false,
- false, false, false, false};
+ false, false, false, false, false};
if (pset.sversion < 100000)
{
@@ -6900,15 +6900,25 @@ describeSubscriptions(const char *pattern, bool verbose)
appendPQExpBuffer(&buf,
", subskiplsn AS \"%s\"\n",
gettext_noop("Skip LSN"));
+
+ /* Conflict log table is only supported in v19 and higher */
+ if (pset.sversion >= 190000)
+ appendPQExpBuffer(&buf,
+ ", (CASE\n"
+ " WHEN subconflictlogtable IS NULL THEN NULL\n"
+ " ELSE pg_catalog.quote_ident(n.nspname) || '.' ||"
+ " pg_catalog.quote_ident(subconflictlogtable::text)\n"
+ "END) AS \"%s\"\n",
+ gettext_noop("Conflict log table"));
}
/* Only display subscriptions in current database. */
- appendPQExpBufferStr(&buf,
- "FROM pg_catalog.pg_subscription\n"
- "WHERE subdbid = (SELECT oid\n"
- " FROM pg_catalog.pg_database\n"
- " WHERE datname = pg_catalog.current_database())");
-
+ appendPQExpBuffer(&buf,
+ "FROM pg_catalog.pg_subscription "
+ "LEFT JOIN pg_catalog.pg_namespace AS n ON subconflictlognspid = n.oid\n"
+ "WHERE subdbid = (SELECT oid\n"
+ " FROM pg_catalog.pg_database\n"
+ " WHERE datname = pg_catalog.current_database())");
if (!validateSQLNamePattern(&buf, pattern, true, false,
NULL, "subname", NULL,
NULL,
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index b1ff6f6cd94..00e45423879 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -2344,8 +2344,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("(", "PUBLICATION");
/* ALTER SUBSCRIPTION <name> SET ( */
else if (Matches("ALTER", "SUBSCRIPTION", MatchAny, MatchAnyN, "SET", "("))
- COMPLETE_WITH("binary", "disable_on_error", "failover",
- "max_retention_duration", "origin",
+ COMPLETE_WITH("binary", "conflict_log_table", "disable_on_error",
+ "failover", "max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
"synchronous_commit", "two_phase");
@@ -3828,8 +3828,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("WITH (");
/* Complete "CREATE SUBSCRIPTION <name> ... WITH ( <opt>" */
else if (Matches("CREATE", "SUBSCRIPTION", MatchAnyN, "WITH", "("))
- COMPLETE_WITH("binary", "connect", "copy_data", "create_slot",
- "disable_on_error", "enabled", "failover",
+ COMPLETE_WITH("binary", "conflict_log_table", "connect", "copy_data",
+ "create_slot", "disable_on_error", "enabled", "failover",
"max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index 55cb9b1eefa..f4526c15ec3 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -80,6 +80,8 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
bool subretaindeadtuples; /* True if dead tuples useful for
* conflict detection are retained */
+ Oid subconflictlognspid; /* Namespace Oid in which the conflict
+ * log table is created. */
int32 submaxretention; /* The maximum duration (in milliseconds)
* for which information useful for
@@ -105,6 +107,9 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
/* Only publish data originating from the specified origin */
text suborigin BKI_DEFAULT(LOGICALREP_ORIGIN_ANY);
+
+ /* Conflict log table name if specified */
+ text subconflictlogtable;
#endif
} FormData_pg_subscription;
diff --git a/src/include/commands/subscriptioncmds.h b/src/include/commands/subscriptioncmds.h
index fb4e26a51a4..6c062b0991f 100644
--- a/src/include/commands/subscriptioncmds.h
+++ b/src/include/commands/subscriptioncmds.h
@@ -36,4 +36,6 @@ extern void CheckSubDeadTupleRetention(bool check_guc, bool sub_disabled,
bool retention_active,
bool max_retention_set);
+extern bool IsConflictLogTable(Oid relid);
+
#endif /* SUBSCRIPTIONCMDS_H */
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index c8fbf9e51b8..c7e67bd300e 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -9,9 +9,12 @@
#ifndef CONFLICT_H
#define CONFLICT_H
+#include "access/htup.h"
#include "access/xlogdefs.h"
+#include "catalog/pg_type.h"
#include "nodes/pg_list.h"
#include "utils/timestamp.h"
+#include "utils/relcache.h"
/* Avoid including execnodes.h here */
typedef struct EState EState;
@@ -79,6 +82,32 @@ typedef struct ConflictTupleInfo
* conflicting local row occurred */
} ConflictTupleInfo;
+/* Structure to hold metadata for one column of the conflict log table */
+typedef struct ConflictLogColumnDef
+{
+ const char *attname; /* Column name */
+ Oid atttypid; /* Data type OID */
+} ConflictLogColumnDef;
+
+/* The single source of truth for the conflict log table schema */
+static const ConflictLogColumnDef ConflictLogSchema[] =
+{
+ { .attname = "relid", .atttypid = OIDOID },
+ { .attname = "schemaname", .atttypid = TEXTOID },
+ { .attname = "relname", .atttypid = TEXTOID },
+ { .attname = "conflict_type", .atttypid = TEXTOID },
+ { .attname = "remote_xid", .atttypid = XIDOID },
+ { .attname = "remote_commit_lsn",.atttypid = LSNOID },
+ { .attname = "remote_commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "remote_origin", .atttypid = TEXTOID },
+ { .attname = "replica_identity", .atttypid = JSONOID },
+ { .attname = "remote_tuple", .atttypid = JSONOID },
+ { .attname = "local_conflicts", .atttypid = JSONARRAYOID }
+};
+
+/* Define the count using the array size */
+#define MAX_CONFLICT_ATTR_NUM (sizeof(ConflictLogSchema) / sizeof(ConflictLogSchema[0]))
+
extern bool GetTupleTransactionInfo(TupleTableSlot *localslot,
TransactionId *xmin,
RepOriginId *localorigin,
@@ -89,4 +118,7 @@ extern void ReportApplyConflict(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *remoteslot,
List *conflicttuples);
extern void InitConflictIndexes(ResultRelInfo *relInfo);
+extern Relation GetConflictLogTableRel(void);
+extern void InsertConflictLogTuple(Relation conflictlogrel);
+extern bool ValidateConflictLogTable(Relation rel);
#endif
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index f081619f151..711c04c7297 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -101,6 +101,9 @@ typedef struct LogicalRepWorker
*/
TransactionId oldest_nonremovable_xid;
+ /* A conflict log tuple that is prepared but not yet inserted. */
+ HeapTuple conflict_log_tuple;
+
/* Stats. */
XLogRecPtr last_lsn;
TimestampTz last_send_time;
@@ -256,6 +259,10 @@ extern PGDLLIMPORT bool InitializingApplyWorker;
extern PGDLLIMPORT List *table_states_not_ready;
+extern XLogRecPtr remote_final_lsn;
+extern TimestampTz remote_commit_ts;
+extern TransactionId remote_xid;
+
extern void logicalrep_worker_attach(int slot);
extern LogicalRepWorker *logicalrep_worker_find(LogicalRepWorkerType wtype,
Oid subid, Oid relid,
diff --git a/src/include/utils/lsyscache.h b/src/include/utils/lsyscache.h
index 50fb149e9ac..3bebf04bf51 100644
--- a/src/include/utils/lsyscache.h
+++ b/src/include/utils/lsyscache.h
@@ -210,6 +210,7 @@ extern Oid get_publication_oid(const char *pubname, bool missing_ok);
extern char *get_publication_name(Oid pubid, bool missing_ok);
extern Oid get_subscription_oid(const char *subname, bool missing_ok);
extern char *get_subscription_name(Oid subid, bool missing_ok);
+extern char *get_subscription_conflict_log_table(Oid subid, Oid *nspid);
#define type_is_array(typid) (get_element_type(typid) != InvalidOid)
/* type_is_array_domain accepts both plain arrays and domains over arrays */
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index 327d1e7731f..f96687e107c 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -116,18 +116,18 @@ CREATE SUBSCRIPTION regress_testsub4 CONNECTION 'dbname=regress_doesnotexist' PU
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub4 SET (origin = any);
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
DROP SUBSCRIPTION regress_testsub3;
@@ -145,10 +145,10 @@ ALTER SUBSCRIPTION regress_testsub CONNECTION 'foobar';
ERROR: invalid connection string syntax: missing "=" after "foobar" in connection info string
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET PUBLICATION testpub2, testpub3 WITH (refresh = false);
@@ -157,10 +157,10 @@ ALTER SUBSCRIPTION regress_testsub SET (slot_name = 'newname');
ALTER SUBSCRIPTION regress_testsub SET (password_required = false);
ALTER SUBSCRIPTION regress_testsub SET (run_as_owner = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (password_required = true);
@@ -176,10 +176,10 @@ ERROR: unrecognized subscription parameter: "create_slot"
-- ok
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/12345');
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345 |
(1 row)
-- ok - with lsn = NONE
@@ -188,10 +188,10 @@ ALTER SUBSCRIPTION regress_testsub SKIP (lsn = NONE);
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/0');
ERROR: invalid WAL location (LSN): 0/0
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 |
(1 row)
BEGIN;
@@ -223,10 +223,10 @@ ALTER SUBSCRIPTION regress_testsub_foo SET (synchronous_commit = foobar);
ERROR: invalid value for parameter "synchronous_commit": "foobar"
HINT: Available values: local, remote_write, remote_apply, on, off.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
----------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+---------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------
+ regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000 |
(1 row)
-- rename back to keep the rest simple
@@ -255,19 +255,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (binary = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -279,27 +279,27 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = parallel);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- fail - publication already exists
@@ -314,10 +314,10 @@ ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refr
ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refresh = false);
ERROR: publication "testpub1" is already in subscription "regress_testsub"
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- fail - publication used more than once
@@ -332,10 +332,10 @@ ERROR: publication "testpub3" is not in subscription "regress_testsub"
-- ok - delete publications
ALTER SUBSCRIPTION regress_testsub DROP PUBLICATION testpub1, testpub2 WITH (refresh = false);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -371,19 +371,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- we can alter streaming when two_phase enabled
ALTER SUBSCRIPTION regress_testsub SET (streaming = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -393,10 +393,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -409,18 +409,18 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (disable_on_error = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -433,10 +433,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -450,19 +450,19 @@ NOTICE: max_retention_duration is ineffective when retain_dead_tuples is disabl
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
-- ok
ALTER SUBSCRIPTION regress_testsub SET (max_retention_duration = 0);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 |
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -517,7 +517,201 @@ COMMIT;
-- ok, owning it is enough for this stuff
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG TABLE TESTS
+--
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+-- fail - conflict_log_table specified when table already exists
+CREATE TABLE public.regress_conflict_log_temp (id int);
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log_temp');
+ERROR: cannot create conflict log table "public.regress_conflict_log_temp" because a table with that name already exists
+HINT: Use a different name for the conflict log table or drop the existing table.
+DROP TABLE public.regress_conflict_log_temp;
+-- ok - conflict_log_table creation with CREATE SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- check metadata in pg_subscription
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname | subconflictlogtable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test1 | regress_conflict_log1 | t
+(1 row)
+
+-- check if the table exists and has the correct schema (11 columns)
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attnum > 0;
+ count
+-------
+ 11
+(1 row)
+
+-- check a specific column type (e.g., remote_tuple should be JSON)
+SELECT format_type(atttypid, atttypmod) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attname = 'remote_tuple';
+ format_type
+-------------
+ json
+(1 row)
+
+\dRs+
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+------------------------------
+ regress_conflict_test1 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | public.regress_conflict_log1
+(1 row)
+
+-- ok - adding conflict_log_table with ALTER SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log2');
+-- check metadata after ALTER
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogtable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test2 | regress_conflict_log2 | t
+(1 row)
+
+-- ok - change the conflict log table name for an existing subscription that already had one
+CREATE SCHEMA clt;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'clt.regress_conflict_log3');
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogtable | is_public_schema
+------------------------+-----------------------+------------------
+ regress_conflict_test2 | regress_conflict_log3 | f
+(1 row)
+
+\dRs+
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log table
+------------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+------------------------------
+ regress_conflict_test1 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | public.regress_conflict_log1
+ regress_conflict_test2 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | clt.regress_conflict_log3
+(2 rows)
+
+-- check the new table was created and the old table was dropped
+SELECT count(*) FROM pg_class WHERE relname = 'regress_conflict_log2';
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'clt.regress_conflict_log3'::regclass AND attnum > 0;
+ count
+-------
+ 11
+(1 row)
+
+-- ok (NOTICE) - set conflict_log_table to one already used by this subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'clt.regress_conflict_log3');
+NOTICE: "clt.regress_conflict_log3" is already in use as the conflict log table for this subscription
+-- fail - try to publish the conflict_log_table
+CREATE PUBLICATION pub FOR TABLE clt.regress_conflict_log3;
+ERROR: cannot add relation "clt.regress_conflict_log3" to publication
+DETAIL: This operation is not supported for conflict log tables.
+-- suppress warning that depends on wal_level
+SET client_min_messages = 'ERROR';
+-- ok - conflict_log_table should not be published with ALL TABLE
+CREATE PUBLICATION pub FOR TABLES IN SCHEMA clt;
+SELECT * FROM pg_publication_tables WHERE pubname = 'pub';
+ pubname | schemaname | tablename | attnames | rowfilter
+---------+------------+-----------+----------+-----------
+(0 rows)
+
+\dt+ clt.regress_conflict_log3
+ List of tables
+ Schema | Name | Type | Owner | Persistence | Size | Description
+--------+-----------------------+-------+---------------------------+-------------+---------+-------------
+ clt | regress_conflict_log3 | table | regress_subscription_user | permanent | 0 bytes |
+(1 row)
+
+DROP PUBLICATION pub;
+-- fail - set conflict_log_table to one already used by a different subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log1');
+ERROR: cannot create conflict log table "public.regress_conflict_log1" because a table with that name already exists
+HINT: Use a different name for the conflict log table or drop the existing table.
+-- ok - dropping subscription also drops the log table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the table was dropped
+SELECT to_regclass('public.regress_conflict_log1');
+ to_regclass
+-------------
+
+(1 row)
+
+-- fail - dropping log table manually not allowed
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+DROP TABLE public.regress_conflict_log1;
+ERROR: cannot drop table regress_conflict_log1 because subscription regress_conflict_test1 requires it
+HINT: You can drop subscription regress_conflict_test1 instead.
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the subscription was dropped successfully
+SELECT subname FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname
+---------
+(0 rows)
+
+-- ok - create subscription with conflict_log_table = NONE
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = NONE);
+SELECT subname, subconflictlogtable FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogtable
+------------------------+-----------------------
+ regress_conflict_test2 | regress_conflict_log3
+(1 row)
+
+-- ok - alter subscription with valid conflict log table name
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log1');
+SELECT subname, subconflictlogtable FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogtable
+------------------------+-----------------------
+ regress_conflict_test2 | regress_conflict_log1
+(1 row)
+
+-- ok - pg_relation_is_publishable should return false for conflict log table
+SELECT pg_relation_is_publishable('public.regress_conflict_log1');
+ pg_relation_is_publishable
+----------------------------
+ f
+(1 row)
+
+-- ok - alter subscription with conflict log table = NONE
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = NONE);
+-- should return NULL, meaning the table was dropped
+SELECT to_regclass('public.regress_conflict_log1');
+ to_regclass
+-------------
+
+(1 row)
+
+SELECT subname, subconflictlogtable FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogtable
+------------------------+---------------------
+ regress_conflict_test2 |
+(1 row)
+
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+-- fail - can not create conflict log table in pg_temp
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'pg_temp.regress_conflict_log1');
+ERROR: cannot create conflict log table "regress_conflict_log1" in a temporary namespace
+HINT: Use a permanent schema.
+-- fail - empty string is not allowed for conflict log table name
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = '');
+ERROR: conflict log table name cannot be empty
+HINT: Provide a valid table name or omit the parameter.
RESET SESSION AUTHORIZATION;
+DROP SCHEMA clt;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
DROP ROLE regress_subscription_user3;
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index ef0c298d2df..6b6f1503145 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -365,7 +365,121 @@ COMMIT;
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG TABLE TESTS
+--
+
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+
+-- fail - conflict_log_table specified when table already exists
+CREATE TABLE public.regress_conflict_log_temp (id int);
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log_temp');
+DROP TABLE public.regress_conflict_log_temp;
+
+-- ok - conflict_log_table creation with CREATE SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+
+-- check metadata in pg_subscription
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- check if the table exists and has the correct schema (11 columns)
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attnum > 0;
+
+-- check a specific column type (e.g., remote_tuple should be JSON)
+SELECT format_type(atttypid, atttypmod) FROM pg_attribute WHERE attrelid = 'public.regress_conflict_log1'::regclass AND attname = 'remote_tuple';
+
+\dRs+
+
+-- ok - adding conflict_log_table with ALTER SUBSCRIPTION
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log2');
+
+-- check metadata after ALTER
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- ok - change the conflict log table name for an existing subscription that already had one
+CREATE SCHEMA clt;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'clt.regress_conflict_log3');
+SELECT subname, subconflictlogtable, subconflictlognspid = (SELECT oid FROM pg_namespace WHERE nspname = 'public') AS is_public_schema
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+\dRs+
+
+-- check the new table was created and the old table was dropped
+SELECT count(*) FROM pg_class WHERE relname = 'regress_conflict_log2';
+SELECT count(*) FROM pg_attribute WHERE attrelid = 'clt.regress_conflict_log3'::regclass AND attnum > 0;
+
+-- ok (NOTICE) - set conflict_log_table to one already used by this subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'clt.regress_conflict_log3');
+
+-- fail - try to publish the conflict_log_table
+CREATE PUBLICATION pub FOR TABLE clt.regress_conflict_log3;
+
+-- suppress warning that depends on wal_level
+SET client_min_messages = 'ERROR';
+
+-- ok - conflict_log_table should not be published with ALL TABLE
+CREATE PUBLICATION pub FOR TABLES IN SCHEMA clt;
+SELECT * FROM pg_publication_tables WHERE pubname = 'pub';
+\dt+ clt.regress_conflict_log3
+DROP PUBLICATION pub;
+
+-- fail - set conflict_log_table to one already used by a different subscription
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log1');
+
+-- ok - dropping subscription also drops the log table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the table was dropped
+SELECT to_regclass('public.regress_conflict_log1');
+
+-- fail - dropping log table manually not allowed
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'public.regress_conflict_log1');
+DROP TABLE public.regress_conflict_log1;
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the subscription was dropped successfully
+SELECT subname FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- ok - create subscription with conflict_log_table = NONE
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = NONE);
+SELECT subname, subconflictlogtable FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+-- ok - alter subscription with valid conflict log table name
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log1');
+SELECT subname, subconflictlogtable FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- ok - pg_relation_is_publishable should return false for conflict log table
+SELECT pg_relation_is_publishable('public.regress_conflict_log1');
+
+-- ok - alter subscription with conflict log table = NONE
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = NONE);
+
+-- should return NULL, meaning the table was dropped
+SELECT to_regclass('public.regress_conflict_log1');
+SELECT subname, subconflictlogtable FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+
+-- fail - can not create conflict log table in pg_temp
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = 'pg_temp.regress_conflict_log1');
+
+-- fail - empty string is not allowed for conflict log table name
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_table = '');
+
RESET SESSION AUTHORIZATION;
+DROP SCHEMA clt;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
DROP ROLE regress_subscription_user3;
--
2.43.0
[text/x-patch] v13-0002-pg_dump-dump-conflict-log-table-configuration-fo.patch (18.7K, ../../CALDaNm3USsXVNBsfdpkp60HVgrTV4taWMk1xZYNBa7QUF=V0jg@mail.gmail.com/3-v13-0002-pg_dump-dump-conflict-log-table-configuration-fo.patch)
download | inline diff:
From 0676b9c1b425ea069a6c38816636034fc090662f Mon Sep 17 00:00:00 2001
From: Vignesh C <vignesh21@gmail.com>
Date: Tue, 16 Dec 2025 09:16:26 +0530
Subject: [PATCH v13 2/2] pg_dump: dump conflict log table configuration for
subscriptions
Allow pg_dump to preserve the conflict_log_table setting of logical
replication subscriptions.
---
src/backend/commands/subscriptioncmds.c | 245 ++++++++++++++-------
src/bin/pg_dump/pg_dump.c | 49 ++++-
src/bin/pg_dump/pg_dump.h | 1 +
src/bin/pg_dump/pg_dump_sort.c | 32 +++
src/bin/pg_dump/t/002_pg_dump.pl | 5 +-
src/test/regress/expected/subscription.out | 15 +-
src/test/regress/sql/subscription.sql | 8 +
7 files changed, 265 insertions(+), 90 deletions(-)
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index b044ed70a2a..569c1a5a76b 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -1242,6 +1242,148 @@ AlterSubscription_refresh(Subscription *sub, bool copy_data,
table_close(rel, NoLock);
}
+/*
+ * AlterSubscriptionConflictLogTable
+ *
+ * Set, change, or remove the conflict log table associated with a
+ * subscription.
+ *
+ * If a conflict log table name is provided, this function validates the
+ * specified relation (or creates it if it does not exist) and records it
+ * as an internal dependency of the subscription. The table must be a
+ * permanent relation in a non temporary schema and must match the expected
+ * conflict log table definition.
+ *
+ * If the subscription already uses the specified conflict log table and the
+ * table still exists, no change is made and a NOTICE is emitted.
+ *
+ * Any previously associated conflict log table is removed by dropping the
+ * subscription's internal dependencies before associating a new table.
+ *
+ * Returns:
+ * NULL when the association has been removed.
+ * else conflict log table associated with the subscription.
+ */
+static char *
+AlterSubscriptionConflictLogTable(Oid subid, char *conflictlogtable,
+ Oid *relnamespaceid)
+{
+ Oid nspid = InvalidOid;
+ Oid old_nspid = InvalidOid;
+ char *old_relname = NULL;
+ char *relname = NULL;
+ List *names = NIL;
+ char *nspname;
+ ObjectAddress object;
+
+ if (conflictlogtable != NULL)
+ {
+ /* Explicitly check for empty string before any processing. */
+ if (conflictlogtable[0] == '\0')
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("conflict log table name cannot be empty"),
+ errhint("Provide a valid table name or omit the parameter.")));
+
+ names = stringToQualifiedNameList(conflictlogtable, NULL);
+ nspid = QualifiedNameGetCreationNamespace(names, &relname);
+ nspname = get_namespace_name(nspid);
+ }
+
+ /* Fetch the existing conflict table information. */
+ old_relname = get_subscription_conflict_log_table(subid, &old_nspid);
+
+ /*
+ * If the subscription already uses this conflict log table and it exists,
+ * just issue a notice.
+ */
+ if (old_relname != NULL && relname != NULL
+ && (strcmp(old_relname, relname) == 0) &&
+ old_nspid == nspid &&
+ OidIsValid(get_relname_relid(old_relname, old_nspid)))
+ {
+ ereport(NOTICE,
+ errmsg("\"%s.%s\" is already in use as the conflict log table for this subscription",
+ nspname, relname));
+ }
+ else
+ {
+ /*
+ * Conflict log tables are recorded as internal dependencies of the
+ * subscription. Before associating a new table, drop the existing
+ * table to avoid stale or orphaned relations.
+ *
+ * XXX: At present, only conflict log tables are managed this way. In
+ * future if we introduce additional internal dependencies, we may
+ * need a targeted deletion to avoid deletion of any other objects.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId, subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
+ /* Need to create a new table if a new name was provided. */
+ if (relname != NULL)
+ {
+ Oid conflictlogrelid = get_relname_relid(relname, nspid);
+
+ if (OidIsValid(conflictlogrelid))
+ {
+ Relation conflictlogrel;
+
+ /*
+ * Conflict log tables must be permanent relations. Disallow
+ * in temporary namespaces to ensure the same.
+ */
+ if (isTempNamespace(nspid))
+ ereport(ERROR,
+ errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot use conflict log table \"%s.%s\" of a temporary namespace",
+ nspname, relname),
+ errhint("Specify table from a permanent schema."));
+
+ conflictlogrel = table_open(conflictlogrelid,
+ RowExclusiveLock);
+
+ if (conflictlogrel->rd_rel->relpersistence != RELPERSISTENCE_PERMANENT)
+ ereport(ERROR,
+ errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("conflict log table \"%s.%s\" must be a permanent table",
+ nspname, relname),
+ errhint("Specify a permanent table as the conflict log table."));
+
+ if (IsConflictLogTable(conflictlogrelid))
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("conflict log table \"%s.%s\" cannot be used",
+ nspname, relname),
+ errdetail("The specified table is already registered for a different subscription."),
+ errhint("Specify a different conflict log table."));
+
+ if (!ValidateConflictLogTable(conflictlogrel))
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("conflict log table \"%s.%s\" has an incompatible definition",
+ nspname, relname),
+ errdetail("The table does not match the required conflict log table structure."),
+ errhint("Create the conflict log table with the expected definition or specify a different table."));
+
+ table_close(conflictlogrel, NoLock);
+
+ }
+ else
+ create_conflict_log_table(nspid, relname, subid);
+ }
+ }
+
+ pfree(nspname);
+ if (old_relname != NULL)
+ pfree(old_relname);
+
+ *relnamespaceid = nspid;
+ return relname;
+}
+
/*
* Marks all sequences with INIT state.
*/
@@ -1727,92 +1869,27 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_LOG_TABLE))
{
- Oid nspid = InvalidOid;
- Oid old_nspid = InvalidOid;
- char *old_relname = NULL;
- char *relname = NULL;
- List *names = NIL;
-
- if (opts.conflictlogtable != NULL)
- {
- /* Explicitly check for empty string before any processing. */
- if (opts.conflictlogtable[0] == '\0')
- ereport(ERROR,
- (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
- errmsg("conflict log table name cannot be empty"),
- errhint("Provide a valid table name or omit the parameter.")));
-
- names = stringToQualifiedNameList(opts.conflictlogtable,
- NULL);
- nspid = QualifiedNameGetCreationNamespace(names, &relname);
- }
-
- /* Fetch the existing conflict table information. */
- old_relname =
- get_subscription_conflict_log_table(subid, &old_nspid);
-
- /*
- * If the subscription already uses this conflict log table
- * and it exists, just issue a notice.
- */
- if (old_relname != NULL && relname != NULL
- && (strcmp(old_relname, relname) == 0) &&
- old_nspid == nspid &&
- OidIsValid(get_relname_relid(old_relname, old_nspid)))
- {
- char *nspname = get_namespace_name(nspid);
-
- ereport(NOTICE,
- (errmsg("\"%s.%s\" is already in use as the conflict log table for this subscription",
- nspname, relname)));
- pfree(nspname);
- }
+ char *relname;
+ Oid nspid;
+ char *conftable = opts.conflictlogtable;
+
+ relname = AlterSubscriptionConflictLogTable(subid,
+ conftable,
+ &nspid);
+ values[Anum_pg_subscription_subconflictlognspid - 1] =
+ ObjectIdGetDatum(nspid);
+
+ if (relname != NULL)
+ values[Anum_pg_subscription_subconflictlogtable - 1] =
+ CStringGetTextDatum(relname);
else
- {
- ObjectAddress object;
-
- /*
- * Conflict log tables are recorded as internal
- * dependencies of the subscription. Before
- * associating a new table, drop the existing table to
- * avoid stale or orphaned relations.
- *
- * XXX: At present, only conflict log tables are
- * managed this way. In future if we introduce
- * additional internal dependencies, we may need
- * a targeted deletion to avoid deletion of any
- * other objects.
- */
- ObjectAddressSet(object, SubscriptionRelationId, subid);
- performDeletion(&object, DROP_CASCADE,
- PERFORM_DELETION_INTERNAL |
- PERFORM_DELETION_SKIP_ORIGINAL);
-
- /*
- * Need to create a new table if a new name was
- * provided.
- */
- if (relname != NULL)
- create_conflict_log_table(nspid, relname, subid);
-
- values[Anum_pg_subscription_subconflictlognspid - 1] =
- ObjectIdGetDatum(nspid);
-
- if (relname != NULL)
- values[Anum_pg_subscription_subconflictlogtable - 1] =
- CStringGetTextDatum(relname);
- else
- nulls[Anum_pg_subscription_subconflictlogtable - 1] =
- true;
-
- replaces[Anum_pg_subscription_subconflictlognspid - 1] =
- true;
- replaces[Anum_pg_subscription_subconflictlogtable - 1] =
- true;
- }
+ nulls[Anum_pg_subscription_subconflictlogtable - 1] =
+ true;
- if (old_relname != NULL)
- pfree(old_relname);
+ replaces[Anum_pg_subscription_subconflictlognspid - 1] =
+ true;
+ replaces[Anum_pg_subscription_subconflictlogtable - 1] =
+ true;
}
update_tuple = true;
diff --git a/src/bin/pg_dump/pg_dump.c b/src/bin/pg_dump/pg_dump.c
index 27f6be3f0f8..f85263b8e12 100644
--- a/src/bin/pg_dump/pg_dump.c
+++ b/src/bin/pg_dump/pg_dump.c
@@ -5130,6 +5130,7 @@ getSubscriptions(Archive *fout)
int i_subfailover;
int i_subretaindeadtuples;
int i_submaxretention;
+ int i_subconflictlogrelid;
int i,
ntups;
@@ -5216,10 +5217,17 @@ getSubscriptions(Archive *fout)
if (fout->remoteVersion >= 190000)
appendPQExpBufferStr(query,
- " s.submaxretention\n");
+ " s.submaxretention,\n");
else
appendPQExpBuffer(query,
- " 0 AS submaxretention\n");
+ " 0 AS submaxretention,\n");
+
+ if (fout->remoteVersion >= 190000)
+ appendPQExpBufferStr(query,
+ " c.oid AS subconflictlogrelid\n");
+ else
+ appendPQExpBufferStr(query,
+ " 0::oid AS subconflictlogrelid\n");
appendPQExpBufferStr(query,
"FROM pg_subscription s\n");
@@ -5229,6 +5237,12 @@ getSubscriptions(Archive *fout)
"LEFT JOIN pg_catalog.pg_replication_origin_status o \n"
" ON o.external_id = 'pg_' || s.oid::text \n");
+ if (fout->remoteVersion >= 190000)
+ appendPQExpBufferStr(query,
+ "LEFT JOIN pg_class c ON c.relname = s.subconflictlogtable\n"
+ "LEFT JOIN pg_namespace n \n"
+ " ON n.oid = c.relnamespace AND n.oid = s.subconflictlognspid\n");
+
appendPQExpBufferStr(query,
"WHERE s.subdbid = (SELECT oid FROM pg_database\n"
" WHERE datname = current_database())");
@@ -5255,6 +5269,7 @@ getSubscriptions(Archive *fout)
i_subfailover = PQfnumber(res, "subfailover");
i_subretaindeadtuples = PQfnumber(res, "subretaindeadtuples");
i_submaxretention = PQfnumber(res, "submaxretention");
+ i_subconflictlogrelid = PQfnumber(res, "subconflictlogrelid");
i_subconninfo = PQfnumber(res, "subconninfo");
i_subslotname = PQfnumber(res, "subslotname");
i_subsynccommit = PQfnumber(res, "subsynccommit");
@@ -5292,6 +5307,19 @@ getSubscriptions(Archive *fout)
(strcmp(PQgetvalue(res, i, i_subretaindeadtuples), "t") == 0);
subinfo[i].submaxretention =
atoi(PQgetvalue(res, i, i_submaxretention));
+ subinfo[i].subconflictlogrelid =
+ atooid(PQgetvalue(res, i, i_subconflictlogrelid));
+
+ if (subinfo[i].subconflictlogrelid != InvalidOid)
+ {
+ TableInfo *tableInfo = findTableByOid(subinfo[i].subconflictlogrelid);
+
+ if (!tableInfo)
+ pg_fatal("could not find conflict log table with OID %u",
+ subinfo[i].subconflictlogrelid);
+
+ addObjectDependency(&subinfo[i].dobj, tableInfo->dobj.dumpId);
+ }
subinfo[i].subconninfo =
pg_strdup(PQgetvalue(res, i, i_subconninfo));
if (PQgetisnull(res, i, i_subslotname))
@@ -5564,6 +5592,23 @@ dumpSubscription(Archive *fout, const SubscriptionInfo *subinfo)
appendPQExpBufferStr(query, ");\n");
+ if (subinfo->subconflictlogrelid != InvalidOid)
+ {
+ PQExpBuffer conflictlogbuf = createPQExpBuffer();
+ TableInfo *tbinfo = findTableByOid(subinfo->subconflictlogrelid);
+
+ appendStringLiteralAH(conflictlogbuf,
+ fmtQualifiedDumpable(tbinfo),
+ fout);
+
+ appendPQExpBuffer(query,
+ "\n\nALTER SUBSCRIPTION %s SET (conflict_log_table = %s);\n",
+ qsubname,
+ conflictlogbuf->data);
+
+ destroyPQExpBuffer(conflictlogbuf);
+ }
+
/*
* In binary-upgrade mode, we allow the replication to continue after the
* upgrade.
diff --git a/src/bin/pg_dump/pg_dump.h b/src/bin/pg_dump/pg_dump.h
index 72a00e1bc20..20ffae491eb 100644
--- a/src/bin/pg_dump/pg_dump.h
+++ b/src/bin/pg_dump/pg_dump.h
@@ -719,6 +719,7 @@ typedef struct _SubscriptionInfo
bool subfailover;
bool subretaindeadtuples;
int submaxretention;
+ Oid subconflictlogrelid;
char *subconninfo;
char *subslotname;
char *subsynccommit;
diff --git a/src/bin/pg_dump/pg_dump_sort.c b/src/bin/pg_dump/pg_dump_sort.c
index e2a4df4cf4b..f0bda51b993 100644
--- a/src/bin/pg_dump/pg_dump_sort.c
+++ b/src/bin/pg_dump/pg_dump_sort.c
@@ -1131,6 +1131,19 @@ repairTableAttrDefMultiLoop(DumpableObject *tableobj,
addObjectDependency(attrdefobj, tableobj->dumpId);
}
+/*
+ * Because we make subscriptions depend on their conflict log tables, while
+ * there is an automatic dependency in the other direction, we need to break
+ * the loop. Remove the automatic dependency, allowing the table to be created
+ * first.
+ */
+static void
+repairSubscriptionTableLoop(DumpableObject *subobj, DumpableObject *tableobj)
+{
+ /* Remove table's dependency on subscription */
+ removeObjectDependency(tableobj, subobj->dumpId);
+}
+
/*
* CHECK, NOT NULL constraints on domains work just like those on tables ...
*/
@@ -1361,6 +1374,25 @@ repairDependencyLoop(DumpableObject **loop,
return;
}
+ /*
+ * Subscription and its Conflict Log Table
+ */
+ if (nLoop == 2 &&
+ loop[0]->objType == DO_TABLE &&
+ loop[1]->objType == DO_SUBSCRIPTION)
+ {
+ repairSubscriptionTableLoop(loop[1], loop[0]);
+ return;
+ }
+
+ if (nLoop == 2 &&
+ loop[0]->objType == DO_SUBSCRIPTION &&
+ loop[1]->objType == DO_TABLE)
+ {
+ repairSubscriptionTableLoop(loop[0], loop[1]);
+ return;
+ }
+
/* index on partitioned table and corresponding index on partition */
if (nLoop == 2 &&
loop[0]->objType == DO_INDEX &&
diff --git a/src/bin/pg_dump/t/002_pg_dump.pl b/src/bin/pg_dump/t/002_pg_dump.pl
index e33aa95f6ff..ef11db6b8ee 100644
--- a/src/bin/pg_dump/t/002_pg_dump.pl
+++ b/src/bin/pg_dump/t/002_pg_dump.pl
@@ -3204,9 +3204,10 @@ my %tests = (
create_order => 50,
create_sql => 'CREATE SUBSCRIPTION sub3
CONNECTION \'dbname=doesnotexist\' PUBLICATION pub1
- WITH (connect = false, origin = any, streaming = on);',
+ WITH (connect = false, origin = any, streaming = on, conflict_log_table = \'conflict\');',
regexp => qr/^
- \QCREATE SUBSCRIPTION sub3 CONNECTION 'dbname=doesnotexist' PUBLICATION pub1 WITH (connect = false, slot_name = 'sub3', streaming = on);\E
+ \QCREATE SUBSCRIPTION sub3 CONNECTION 'dbname=doesnotexist' PUBLICATION pub1 WITH (connect = false, slot_name = 'sub3', streaming = on);\E\n\n\n
+ \QALTER SUBSCRIPTION sub3 SET (conflict_log_table = 'public.conflict');\E
/xm,
like => { %full_runs, section_post_data => 1, },
unlike => {
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index f96687e107c..665a8fcc0aa 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -630,8 +630,19 @@ SELECT * FROM pg_publication_tables WHERE pubname = 'pub';
DROP PUBLICATION pub;
-- fail - set conflict_log_table to one already used by a different subscription
ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log1');
-ERROR: cannot create conflict log table "public.regress_conflict_log1" because a table with that name already exists
-HINT: Use a different name for the conflict log table or drop the existing table.
+ERROR: conflict log table "public.regress_conflict_log1" cannot be used
+DETAIL: The specified table is already registered for a different subscription.
+HINT: Specify a different conflict log table.
+-- fail - conflict log table must be a permanent relation (UNLOGGED not allowed)
+CREATE UNLOGGED TABLE public.regress_conflict_log_unlogged (id int);
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log_unlogged');
+ERROR: conflict log table "public.regress_conflict_log_unlogged" must be a permanent table
+HINT: Specify a permanent table as the conflict log table.
+DROP TABLE public.regress_conflict_log_unlogged;
+-- fail - conflict log table must not be in a temporary schema
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'pg_temp.regress_conflict_log1');
+ERROR: cannot create conflict log table "regress_conflict_log1" in a temporary namespace
+HINT: Use a permanent schema.
-- ok - dropping subscription also drops the log table
ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index 6b6f1503145..5dd31b5ed12 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -428,6 +428,14 @@ DROP PUBLICATION pub;
-- fail - set conflict_log_table to one already used by a different subscription
ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log1');
+-- fail - conflict log table must be a permanent relation (UNLOGGED not allowed)
+CREATE UNLOGGED TABLE public.regress_conflict_log_unlogged (id int);
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'public.regress_conflict_log_unlogged');
+DROP TABLE public.regress_conflict_log_unlogged;
+
+-- fail - conflict log table must not be in a temporary schema
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_table = 'pg_temp.regress_conflict_log1');
+
-- ok - dropping subscription also drops the log table
ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
--
2.43.0
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-20 11:20 Dilip Kumar <dilipbalaut@gmail.com>
parent: vignesh C <vignesh21@gmail.com>
0 siblings, 6 replies; 912+ messages in thread
From: Dilip Kumar @ 2025-12-20 11:20 UTC (permalink / raw)
To: vignesh C <vignesh21@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Sat, Dec 20, 2025 at 3:17 PM vignesh C <vignesh21@gmail.com> wrote:
>
> On Tue, 16 Dec 2025 at 09:54, vignesh C <vignesh21@gmail.com> wrote:
> >
> > On Sun, 14 Dec 2025 at 21:17, Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > On Sun, Dec 14, 2025 at 3:51 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > >
> > > > On Fri, Dec 12, 2025 at 3:04 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > > > >
> > > > > On Thu, Dec 11, 2025 at 7:49 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > > > >
> > > > > > I was considering the interdependence between the subscription and the
> > > > > > conflict log table (CLT). IMHO, it would be logical to establish the
> > > > > > subscription as dependent on the CLT. This way, if someone attempts to
> > > > > > drop the CLT, the system would recognize the dependency of the
> > > > > > subscription and prevent the drop unless the subscription is removed
> > > > > > first or the CASCADE option is used.
> > > > > >
> > > > > > However, while investigating this, I encountered an error [1] stating
> > > > > > that global objects are not supported in this context. This indicates
> > > > > > that global objects cannot be made dependent on local objects.
> > > > > >
> > > > >
> > > > > What we need here is an equivalent of DEPENDENCY_INTERNAL for database
> > > > > objects. For example, consider following case:
> > > > > postgres=# create table t1(c1 int primary key);
> > > > > CREATE TABLE
> > > > > postgres=# \d+ t1
> > > > > Table "public.t1"
> > > > > Column | Type | Collation | Nullable | Default | Storage |
> > > > > Compression | Stats target | Description
> > > > > --------+---------+-----------+----------+---------+---------+-------------+--------------+-------------
> > > > > c1 | integer | | not null | | plain |
> > > > > | |
> > > > > Indexes:
> > > > > "t1_pkey" PRIMARY KEY, btree (c1)
> > > > > Publications:
> > > > > "pub1"
> > > > > Not-null constraints:
> > > > > "t1_c1_not_null" NOT NULL "c1"
> > > > > Access method: heap
> > > > > postgres=# drop index t1_pkey;
> > > > > ERROR: cannot drop index t1_pkey because constraint t1_pkey on table
> > > > > t1 requires it
> > > > > HINT: You can drop constraint t1_pkey on table t1 instead.
> > > > >
> > > > > Here, the PK index is created as part for CREATE TABLE operation and
> > > > > pk_index is not allowed to be dropped independently.
> > > > >
> > > > > > Although making an object dependent on global/shared objects is
> > > > > > possible for certain types of shared objects [2], this is not our main
> > > > > > objective.
> > > > > >
> > > > >
> > > > > As per my understanding from the above example, we need something like
> > > > > that only for shared object subscription and (internally created)
> > > > > table.
> > > >
> > > > Yeah that seems to be exactly what we want, so I tried doing that by
> > > > recording DEPENDENCY_INTERNAL dependency of CLT on subscription[1] and
> > > > it is behaving as we want[2]. And while dropping the subscription or
> > > > altering CLT we can delete internal dependency so that CLT get dropped
> > > > automatically[3]
> > > >
> > > > I will send an updated patch after testing a few more scenarios and
> > > > fixing other pending issues.
> > > >
> > > > [1]
> > > > + ObjectAddressSet(myself, RelationRelationId, relid);
> > > > + ObjectAddressSet(subaddr, SubscriptionRelationId, subid);
> > > > + recordDependencyOn(&myself, &subaddr, DEPENDENCY_INTERNAL);
> > > >
> > > >
> > > > [2]
> > > > postgres[670778]=# DROP TABLE myschema.conflict_log_history2;
> > > > ERROR: 2BP01: cannot drop table myschema.conflict_log_history2
> > > > because subscription sub requires it
> > > > HINT: You can drop subscription sub instead.
> > > > LOCATION: findDependentObjects, dependency.c:788
> > > > postgres[670778]=#
> > > >
> > > > [3]
> > > > ObjectAddressSet(object, SubscriptionRelationId, subid);
> > > > performDeletion(&object, DROP_CASCADE
> > > > PERFORM_DELETION_INTERNAL |
> > > > PERFORM_DELETION_SKIP_ORIGINAL);
> > > >
> > > >
> > >
> > > Here is the patch which implements the dependency and fixes other
> > > comments from Shveta.
> >
> > Thanks for the changes, the new implementation based on dependency
> > creates a cycle while dumping:
> > ./pg_dump -d postgres -f dump1.txt -p 5433
> > pg_dump: warning: could not resolve dependency loop among these items:
> > pg_dump: detail: TABLE conflict (ID 225 OID 16397)
> > pg_dump: detail: SUBSCRIPTION (ID 3484 OID 16396)
> > pg_dump: detail: POST-DATA BOUNDARY (ID 3491)
> > pg_dump: detail: TABLE DATA t1 (ID 3485 OID 16384)
> > pg_dump: detail: PRE-DATA BOUNDARY (ID 3490)
> >
> > This can be seen with a simple subscription with conflict_log_table.
> > This was working fine with the v11 version patch.
>
> The attached v13 patch includes the fix for this issue. In addition,
> it now raises an error when attempting to configure a conflict log
> table that belongs to a temporary schema or is not a permanent
> (persistent) relation.
I have updated the patch and here are changes done
1. Splitted into 2 patches, 0001- for catalog related changes
0002-inserting conflict into the conflict table, Vignesh need to
rebase the dump and upgrade related patch on this latest changes
2. Subscription option changed to conflict_log_destination=(log/table/all/'')
3. For internal processing we will use ConflictLogDest enum whereas
for taking input or storing into catalog we will use string [1].
4. As suggested by Sawada San, if conflict_log_destination is 'table'
we log the information about conflict but don't log the tuple
details[3]
Pending:
1. tap test for conflict insertion
2. Still need to work on caching related changes discussed at [2], so
currently we don't allow conflict log tables to be added to
publication at all and might change this behavior as discussed at [2]
and for that we will need to implement the caching.
3. Need to add conflict insertion test and doc changes.
4. Still need to check on the latest comments from Peter Smith.
[1]
typedef enum ConflictLogDest
{
CONFLICT_LOG_DEST_INVALID = 0,
CONFLICT_LOG_DEST_LOG, /* "log" (default) */
CONFLICT_LOG_DEST_TABLE, /* "table" */
CONFLICT_LOG_DEST_ALL /* "all" */
} ConflictLogDest;
/*
* Array mapping for converting internal enum to string.
*/
static const char *const ConflictLogDestLabels[] = {
[CONFLICT_LOG_DEST_LOG] = "log",
[CONFLICT_LOG_DEST_TABLE] = "table",
[CONFLICT_LOG_DEST_ALL] = "all"
};
[2] https://www.postgresql.org/message-id/CAA4eK1LNjWigHb5YKz2nBwcGQr18WnNZHv3Gyo8GNCshSkAb-A%40mail.gma...
[3]
/* Decide what detail to show in server logs. */
if (dest == CONFLICT_LOG_DEST_LOG || dest == CONFLICT_LOG_DEST_ALL)
{
/* Standard reporting with full internal details. */
ereport(elevel,
errcode_apply_conflict(type),
errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
get_namespace_name(RelationGetNamespace(localrel)),
RelationGetRelationName(localrel),
ConflictTypeNames[type]),
errdetail_internal("%s", err_detail.data));
}
else
{
/*
* 'table' only: Report the error msg but omit raw tuple data from
* server logs since it's already captured in the internal table.
*/
ereport(elevel,
errcode_apply_conflict(type),
errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
get_namespace_name(RelationGetNamespace(localrel)),
RelationGetRelationName(localrel),
ConflictTypeNames[type]),
errdetail("Conflict details logged to internal table with OID %u.",
MySubscription->conflictrelid));
}
--
Regards,
Dilip Kumar
Google
Attachments:
[application/octet-stream] v14-0001-Add-configurable-conflict-log-table-for-Logical-.patch (97.0K, ../../CAFiTN-sNg9ghLNkB2Kn0SwBGOub9acc99XZZU_d5NAcyW-yrEg@mail.gmail.com/2-v14-0001-Add-configurable-conflict-log-table-for-Logical-.patch)
download | inline diff:
From 3c807967dbcdb27e665bf14cdb69c2cd0dcf02c2 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 17 Dec 2025 11:53:47 +0530
Subject: [PATCH v14 1/2] Add configurable conflict log table for Logical
Replication
This patch adds a feature to provide a structured, queryable record of all
logical replication conflicts. The current approach of logging conflicts as
plain text in the server logs makes it difficult to query, analyze, and
use for external monitoring and automation.
This patch addresses these limitations by introducing a configurable
conflict_log_destination=(log/table/all) option in the CREATE SUBSCRIPTION
command.
If user choose to log into the table the table will automatically created while
creating the subscription with internal name i.e. conflict_log_table_$subid$. The
table will be created in the current search path and table would be automatically
dropped while dropping the subscription.
The conflict details, including the original and remote tuples, are stored in JSON
columns, providing a flexible format to accommodate different table schemas.
The log table captures essential attributes such as local and remote transaction IDs,
LSNs, commit timestamps, and conflict type, providing a complete record for post-mortem
analysis.
This feature will make logical replication conflicts easier to monitor and manage,
significantly improving the overall resilience and operability of replication setups.
The conflict log tables will not be included in a publication, even if the publication
is configured to include ALL TABLES or ALL TABLES IN SCHEMA.
---
src/backend/catalog/pg_publication.c | 26 +-
src/backend/catalog/pg_subscription.c | 7 +
src/backend/commands/subscriptioncmds.c | 333 ++++++++++++++++++++-
src/bin/psql/describe.c | 21 +-
src/bin/psql/tab-complete.in.c | 8 +-
src/include/catalog/pg_subscription.h | 11 +
src/include/commands/subscriptioncmds.h | 5 +
src/include/replication/conflict.h | 50 ++++
src/test/regress/expected/subscription.out | 327 ++++++++++++++------
src/test/regress/sql/subscription.sql | 108 +++++++
10 files changed, 792 insertions(+), 104 deletions(-)
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index 7aa3f179924..9f84e02b7ef 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -31,6 +31,7 @@
#include "catalog/pg_publication_rel.h"
#include "catalog/pg_type.h"
#include "commands/publicationcmds.h"
+#include "commands/subscriptioncmds.h"
#include "funcapi.h"
#include "utils/array.h"
#include "utils/builtins.h"
@@ -85,6 +86,15 @@ check_publication_add_relation(Relation targetrel)
errmsg("cannot add relation \"%s\" to publication",
RelationGetRelationName(targetrel)),
errdetail("This operation is not supported for unlogged tables.")));
+
+ /* Can't be conflict log table */
+ if (IsConflictLogTable(RelationGetRelid(targetrel)))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot add relation \"%s.%s\" to publication",
+ get_namespace_name(RelationGetNamespace(targetrel)),
+ RelationGetRelationName(targetrel)),
+ errdetail("This operation is not supported for conflict log tables.")));
}
/*
@@ -145,6 +155,13 @@ is_publishable_class(Oid relid, Form_pg_class reltuple)
/*
* Another variant of is_publishable_class(), taking a Relation.
+ *
+ * Note: Conflict log tables are not publishable. However, we intentionally
+ * skip this check here because this function is called for every change and
+ * performing this check during every change publication is costly. To ensure
+ * unpublishable entries are ignored without incurring performance overhead,
+ * tuples inserted into the conflict log table uses the HEAP_INSERT_NO_LOGICAL
+ * flag. This allows the decoding layer to bypass these entries automatically.
*/
bool
is_publishable_relation(Relation rel)
@@ -169,7 +186,10 @@ pg_relation_is_publishable(PG_FUNCTION_ARGS)
tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
if (!HeapTupleIsValid(tuple))
PG_RETURN_NULL();
- result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple));
+
+ /* Subscription conflict log tables are not published */
+ result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple)) &&
+ !IsConflictLogTable(relid);
ReleaseSysCache(tuple);
PG_RETURN_BOOL(result);
}
@@ -890,7 +910,9 @@ GetAllPublicationRelations(char relkind, bool pubviaroot)
Form_pg_class relForm = (Form_pg_class) GETSTRUCT(tuple);
Oid relid = relForm->oid;
+ /* Subscription conflict log tables are not published */
if (is_publishable_class(relid, relForm) &&
+ !IsConflictLogTable(relid) &&
!(relForm->relispartition && pubviaroot))
result = lappend_oid(result, relid);
}
@@ -1018,7 +1040,7 @@ GetSchemaPublicationRelations(Oid schemaid, PublicationPartOpt pub_partopt)
Oid relid = relForm->oid;
char relkind;
- if (!is_publishable_class(relid, relForm))
+ if (!is_publishable_class(relid, relForm) || IsConflictLogTable(relid))
continue;
relkind = get_rel_relkind(relid);
diff --git a/src/backend/catalog/pg_subscription.c b/src/backend/catalog/pg_subscription.c
index ad6fbd77ffd..27a9aee1c56 100644
--- a/src/backend/catalog/pg_subscription.c
+++ b/src/backend/catalog/pg_subscription.c
@@ -106,6 +106,7 @@ GetSubscription(Oid subid, bool missing_ok)
sub->retaindeadtuples = subform->subretaindeadtuples;
sub->maxretention = subform->submaxretention;
sub->retentionactive = subform->subretentionactive;
+ sub->conflictrelid = subform->subconflictlogrelid;
/* Get conninfo */
datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
@@ -141,6 +142,12 @@ GetSubscription(Oid subid, bool missing_ok)
Anum_pg_subscription_suborigin);
sub->origin = TextDatumGetCString(datum);
+ /* Get conflict log destination */
+ datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
+ tup,
+ Anum_pg_subscription_sublogdestination);
+ sub->logdestination = TextDatumGetCString(datum);
+
/* Is the subscription owner a superuser? */
sub->ownersuperuser = superuser_arg(sub->owner);
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index abbcaff0838..65c4c0dd8e4 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -15,16 +15,19 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/htup_details.h"
#include "access/table.h"
#include "access/twophase.h"
#include "access/xact.h"
#include "catalog/catalog.h"
#include "catalog/dependency.h"
+#include "catalog/heap.h"
#include "catalog/indexing.h"
#include "catalog/namespace.h"
#include "catalog/objectaccess.h"
#include "catalog/objectaddress.h"
+#include "catalog/pg_am_d.h"
#include "catalog/pg_authid_d.h"
#include "catalog/pg_database_d.h"
#include "catalog/pg_subscription.h"
@@ -34,6 +37,7 @@
#include "commands/event_trigger.h"
#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "miscadmin.h"
#include "nodes/makefuncs.h"
#include "pgstat.h"
@@ -51,6 +55,7 @@
#include "utils/lsyscache.h"
#include "utils/memutils.h"
#include "utils/pg_lsn.h"
+#include "utils/regproc.h"
#include "utils/syscache.h"
/*
@@ -75,6 +80,7 @@
#define SUBOPT_MAX_RETENTION_DURATION 0x00008000
#define SUBOPT_LSN 0x00010000
#define SUBOPT_ORIGIN 0x00020000
+#define SUBOPT_CONFLICT_LOG_DESTINATION 0x00040000
/* check if the 'val' has 'bits' set */
#define IsSet(val, bits) (((val) & (bits)) == (bits))
@@ -103,6 +109,7 @@ typedef struct SubOpts
bool retaindeadtuples;
int32 maxretention;
char *origin;
+ ConflictLogDest logdest;
XLogRecPtr lsn;
} SubOpts;
@@ -135,7 +142,8 @@ static List *merge_publications(List *oldpublist, List *newpublist, bool addpub,
static void ReportSlotConnectionError(List *rstates, Oid subid, char *slotname, char *err);
static void CheckAlterSubOption(Subscription *sub, const char *option,
bool slot_needs_update, bool isTopLevel);
-
+static Oid create_conflict_log_table(Oid namespaceId, char *conflictrel,
+ Oid subid);
/*
* Common option parsing function for CREATE and ALTER SUBSCRIPTION commands.
@@ -191,6 +199,8 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->maxretention = 0;
if (IsSet(supported_opts, SUBOPT_ORIGIN))
opts->origin = pstrdup(LOGICALREP_ORIGIN_ANY);
+ if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DESTINATION))
+ opts->logdest = CONFLICT_LOG_DEST_LOG;
/* Parse options */
foreach(lc, stmt_options)
@@ -402,6 +412,28 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->specified_opts |= SUBOPT_LSN;
opts->lsn = lsn;
}
+ else if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DESTINATION) &&
+ strcmp(defel->defname, "conflict_log_destination") == 0)
+ {
+ char *val;
+ ConflictLogDest dest;
+
+ if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_LOG_DESTINATION))
+ errorConflictingDefElem(defel, pstate);
+
+ val = defGetString(defel);
+
+ dest = GetLogDestination(val);
+
+ if (dest == CONFLICT_LOG_DEST_INVALID)
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("unrecognized conflict_log_destination value: \"%s\"", val),
+ errhint("Valid values are \"log\", \"table\", and \"all\".")));
+
+ opts->logdest = dest;
+ opts->specified_opts |= SUBOPT_CONFLICT_LOG_DESTINATION;
+ }
else
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
@@ -612,7 +644,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
SUBOPT_DISABLE_ON_ERR | SUBOPT_PASSWORD_REQUIRED |
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
- SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN);
+ SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_DESTINATION);
parse_subscription_options(pstate, stmt->options, supported_opts, &opts);
/*
@@ -747,6 +780,40 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
values[Anum_pg_subscription_suborigin - 1] =
CStringGetTextDatum(opts.origin);
+ /* Always set the destination, default will be log. */
+ values[Anum_pg_subscription_sublogdestination - 1] =
+ CStringGetTextDatum(ConflictLogDestLabels[opts.logdest]);
+
+ /*
+ * If the conflict log destination includes 'table', generate an internal
+ * name using the subscription OID and determine the target namespace based
+ * on the current search path. Store the namespace OID and the conflict log
+ * format in the pg_subscription catalog tuple., then physically create
+ * the table.
+ */
+ if (opts.logdest == CONFLICT_LOG_DEST_TABLE ||
+ opts.logdest == CONFLICT_LOG_DEST_ALL)
+ {
+ char conflict_table_name[NAMEDATALEN];
+ Oid namespaceId, logrelid;
+
+ GetConflictLogTableName(conflict_table_name, subid);
+ namespaceId = RangeVarGetCreationNamespace(
+ makeRangeVar(NULL, conflict_table_name, -1));
+
+ /* Store the Oid returned from creation. */
+ logrelid = create_conflict_log_table(namespaceId, conflict_table_name,
+ subid);
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(logrelid);
+ }
+ else
+ {
+ /* Destination is "log"; no table is needed. */
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(InvalidOid);
+ }
+
tup = heap_form_tuple(RelationGetDescr(rel), values, nulls);
/* Insert tuple into catalog. */
@@ -1410,7 +1477,8 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
SUBOPT_MAX_RETENTION_DURATION |
- SUBOPT_ORIGIN);
+ SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_DESTINATION);
parse_subscription_options(pstate, stmt->options,
supported_opts, &opts);
@@ -1665,6 +1733,73 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
origin = opts.origin;
}
+ if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_LOG_DESTINATION))
+ {
+ ConflictLogDest old_dest =
+ GetLogDestination(sub->logdestination);
+
+ if (opts.logdest != old_dest)
+ {
+ bool want_table =
+ (opts.logdest == CONFLICT_LOG_DEST_TABLE ||
+ opts.logdest == CONFLICT_LOG_DEST_ALL);
+ bool has_oldtable =
+ (old_dest == CONFLICT_LOG_DEST_TABLE ||
+ old_dest == CONFLICT_LOG_DEST_ALL);
+
+ values[Anum_pg_subscription_sublogdestination - 1] =
+ CStringGetTextDatum(ConflictLogDestLabels[opts.logdest]);
+ replaces[Anum_pg_subscription_sublogdestination - 1] = true;
+
+ if (want_table && !has_oldtable)
+ {
+ char relname[NAMEDATALEN];
+ Oid nspid;
+ Oid relid;
+
+ GetConflictLogTableName(relname, subid);
+ nspid = RangeVarGetCreationNamespace(makeRangeVar(
+ NULL, relname, -1));
+
+ relid = create_conflict_log_table(nspid,
+ relname,
+ subid);
+
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(relid);
+ replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
+ true;
+ }
+ else if (!want_table && has_oldtable)
+ {
+ ObjectAddress object;
+
+ /*
+ * Conflict log tables are recorded as internal
+ * dependencies of the subscription. Drop the
+ * table if it is not required anymore to avoid
+ * stale or orphaned relations.
+ *
+ * XXX: At present, only conflict log tables are
+ * managed this way. In future if we introduce
+ * additional internal dependencies, we may need
+ * a targeted deletion to avoid deletion of any
+ * other objects.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId,
+ subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(InvalidOid);
+ replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
+ true;
+ }
+ }
+ }
+
update_tuple = true;
break;
}
@@ -2027,6 +2162,7 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
Form_pg_subscription form;
List *rstates;
bool must_use_password;
+ ObjectAddress object;
/*
* The launcher may concurrently start a new worker for this subscription.
@@ -2184,6 +2320,19 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
/* Clean up dependencies */
deleteSharedDependencyRecordsFor(SubscriptionRelationId, subid, 0);
+ /*
+ * Conflict log tables are recorded as internal dependencies of the
+ * subscription. We must drop the dependent objects before the
+ * subscription itself is removed. By using
+ * PERFORM_DELETION_SKIP_ORIGINAL, we ensure that only the conflict log
+ * table is reaped while the subscription remains for the final deletion
+ * step.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId, subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
/* Remove any associated relation synchronization states. */
RemoveSubscriptionRel(subid, InvalidOid);
@@ -3188,3 +3337,181 @@ defGetStreamingMode(DefElem *def)
def->defname)));
return LOGICALREP_STREAM_OFF; /* keep compiler quiet */
}
+
+/*
+ * Builds the TupleDesc for the conflict log table.
+ */
+static TupleDesc
+create_conflict_log_table_tupdesc(void)
+{
+ TupleDesc tupdesc;
+ int i;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_CONFLICT_ATTR_NUM);
+
+ for (i = 0; i < MAX_CONFLICT_ATTR_NUM; i++)
+ {
+ Oid type_oid = ConflictLogSchema[i].atttypid;
+
+ /*
+ * Special handling for the JSON array type for proper
+ * TupleDescInitEntry call.
+ */
+ if (type_oid == JSONARRAYOID)
+ type_oid = get_array_type(JSONOID);
+
+ TupleDescInitEntry(tupdesc, i + 1,
+ ConflictLogSchema[i].attname,
+ type_oid,
+ -1, 0);
+ }
+
+ return BlessTupleDesc(tupdesc);
+}
+
+/*
+ * Create conflict log table.
+ *
+ * The subscription owner becomes the owner of this table and has all
+ * privileges on it.
+ */
+static Oid
+create_conflict_log_table(Oid namespaceId, char *conflictrel, Oid subid)
+{
+ TupleDesc tupdesc;
+ Oid relid;
+ ObjectAddress myself;
+ ObjectAddress subaddr;
+
+ /* Report an error if the specified conflict log table already exists. */
+ if (OidIsValid(get_relname_relid(conflictrel, namespaceId)))
+ ereport(ERROR,
+ (errcode(ERRCODE_DUPLICATE_TABLE),
+ errmsg("could not generate conflict log table \"%s.%s\"",
+ get_namespace_name(namespaceId), conflictrel),
+ errdetail("A table with the internally generated name already exists."),
+ errhint("Drop the existing table or change your 'search_path' to use a different schema.")));
+
+ /*
+ * Conflict log tables must be permanent relations. Disallow creation in
+ * temporary namespaces to ensure the same.
+ */
+ if (isTempNamespace(namespaceId))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("could not generate conflict log table \"%s\"",
+ conflictrel),
+ errdetail("Conflict log tables cannot be created in a temporary namespace."),
+ errhint("Ensure your 'search_path' is set to permanent schema.")));
+
+ /* Build the tuple descriptor for the new table. */
+ tupdesc = create_conflict_log_table_tupdesc();
+
+ /* Create conflict log table. */
+ relid = heap_create_with_catalog(conflictrel,
+ namespaceId,
+ 0,
+ InvalidOid,
+ InvalidOid,
+ InvalidOid,
+ GetUserId(),
+ HEAP_TABLE_AM_OID,
+ tupdesc,
+ NIL,
+ RELKIND_RELATION,
+ RELPERSISTENCE_PERMANENT,
+ false,
+ false,
+ ONCOMMIT_NOOP,
+ (Datum) 0,
+ false,
+ false,
+ false,
+ InvalidOid,
+ NULL);
+
+ /*
+ * Establish an internal dependency between the conflict log table and
+ * the subscription. By using DEPENDENCY_INTERNAL, we ensure the table
+ * is automatically reaped when the subscription is dropped. This also
+ * prevents the table from being dropped independently unless the
+ * subscription itself is removed.
+ */
+ ObjectAddressSet(myself, RelationRelationId, relid);
+ ObjectAddressSet(subaddr, SubscriptionRelationId, subid);
+ recordDependencyOn(&myself, &subaddr, DEPENDENCY_INTERNAL);
+
+ /* Release tuple descriptor memory. */
+ FreeTupleDesc(tupdesc);
+
+ return relid;
+}
+
+/*
+ * Format the standardized internal conflict log table name for a subscription
+ *
+ * Use the OID to prevent collisions during rename operations.
+ */
+void
+GetConflictLogTableName(char *dest, Oid subid)
+{
+ snprintf(dest, NAMEDATALEN, "conflict_log_table_%u", subid);
+}
+
+/*
+ * GetLogDestination
+ *
+ * Convert string to enum by comparing against standardized labels.
+ */
+ConflictLogDest
+GetLogDestination(const char *dest)
+{
+ int i;
+
+ /* Empty string or NULL defaults to LOG. */
+ if (dest == NULL || dest[0] == '\0')
+ return CONFLICT_LOG_DEST_LOG;
+
+ for (i = CONFLICT_LOG_DEST_LOG; i <= CONFLICT_LOG_DEST_ALL; i++)
+ {
+ if (pg_strcasecmp(dest, ConflictLogDestLabels[i]) == 0)
+ return (ConflictLogDest) i;
+ }
+
+ /* Unrecognized string. */
+ return CONFLICT_LOG_DEST_INVALID;
+}
+
+/*
+ * Check if the specified relation is used as a conflict log table by any
+ * subscription.
+ */
+bool
+IsConflictLogTable(Oid relid)
+{
+ Relation rel;
+ TableScanDesc scan;
+ HeapTuple tup;
+ bool is_clt = false;
+
+ rel = table_open(SubscriptionRelationId, AccessShareLock);
+ scan = table_beginscan_catalog(rel, 0, NULL);
+
+ while (HeapTupleIsValid(tup = heap_getnext(scan, ForwardScanDirection)))
+ {
+ Form_pg_subscription subform = (Form_pg_subscription) GETSTRUCT(tup);
+
+ /* Direct Oid comparison from catalog */
+ if (OidIsValid(subform->subconflictlogrelid) &&
+ subform->subconflictlogrelid == relid)
+ {
+ is_clt = true;
+ break;
+ }
+ }
+
+ table_endscan(scan);
+ table_close(rel, AccessShareLock);
+
+ return is_clt;
+}
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index 36f24502842..cc80f0f661c 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -6806,7 +6806,7 @@ describeSubscriptions(const char *pattern, bool verbose)
printQueryOpt myopt = pset.popt;
static const bool translate_columns[] = {false, false, false, false,
false, false, false, false, false, false, false, false, false, false,
- false, false, false, false};
+ false, false, false, false, false};
if (pset.sversion < 100000)
{
@@ -6900,15 +6900,22 @@ describeSubscriptions(const char *pattern, bool verbose)
appendPQExpBuffer(&buf,
", subskiplsn AS \"%s\"\n",
gettext_noop("Skip LSN"));
+
+ /* Conflict log destination is supported in v19 and higher */
+ if (pset.sversion >= 190000)
+ {
+ appendPQExpBuffer(&buf,
+ ", sublogdestination AS \"%s\"\n",
+ gettext_noop("Conflict log destination"));
+ }
}
/* Only display subscriptions in current database. */
- appendPQExpBufferStr(&buf,
- "FROM pg_catalog.pg_subscription\n"
- "WHERE subdbid = (SELECT oid\n"
- " FROM pg_catalog.pg_database\n"
- " WHERE datname = pg_catalog.current_database())");
-
+ appendPQExpBuffer(&buf,
+ "FROM pg_catalog.pg_subscription "
+ "WHERE subdbid = (SELECT oid\n"
+ " FROM pg_catalog.pg_database\n"
+ " WHERE datname = pg_catalog.current_database())");
if (!validateSQLNamePattern(&buf, pattern, true, false,
NULL, "subname", NULL,
NULL,
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index b1ff6f6cd94..fd279d98b1b 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -2344,8 +2344,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("(", "PUBLICATION");
/* ALTER SUBSCRIPTION <name> SET ( */
else if (Matches("ALTER", "SUBSCRIPTION", MatchAny, MatchAnyN, "SET", "("))
- COMPLETE_WITH("binary", "disable_on_error", "failover",
- "max_retention_duration", "origin",
+ COMPLETE_WITH("binary", "conflict_log_destination", "disable_on_error",
+ "failover", "max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
"synchronous_commit", "two_phase");
@@ -3828,8 +3828,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("WITH (");
/* Complete "CREATE SUBSCRIPTION <name> ... WITH ( <opt>" */
else if (Matches("CREATE", "SUBSCRIPTION", MatchAnyN, "WITH", "("))
- COMPLETE_WITH("binary", "connect", "copy_data", "create_slot",
- "disable_on_error", "enabled", "failover",
+ COMPLETE_WITH("binary", "conflict_log_destination", "connect", "copy_data",
+ "create_slot", "disable_on_error", "enabled", "failover",
"max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index 55cb9b1eefa..55f4bfa0419 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -90,6 +90,7 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
* exceeded max_retention_duration, when
* defined */
+ Oid subconflictlogrelid; /* Relid of the conflict log table. */
#ifdef CATALOG_VARLEN /* variable-length fields start here */
/* Connection string to the publisher */
text subconninfo BKI_FORCE_NOT_NULL;
@@ -103,6 +104,14 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
/* List of publications subscribed to */
text subpublications[1] BKI_FORCE_NOT_NULL;
+ /*
+ * Strategy for logging replication conflicts:
+ * log - server log only,
+ * table - internal table only,
+ * all - both log and table.
+ */
+ text sublogdestination;
+
/* Only publish data originating from the specified origin */
text suborigin BKI_DEFAULT(LOGICALREP_ORIGIN_ANY);
#endif
@@ -152,12 +161,14 @@ typedef struct Subscription
* and the retention duration has not
* exceeded max_retention_duration, when
* defined */
+ Oid conflictrelid; /* conflict log table Oid */
char *conninfo; /* Connection string to the publisher */
char *slotname; /* Name of the replication slot */
char *synccommit; /* Synchronous commit setting for worker */
List *publications; /* List of publication names to subscribe to */
char *origin; /* Only publish data originating from the
* specified origin */
+ char *logdestination; /* Conflict log destination */
} Subscription;
#ifdef EXPOSE_TO_CLIENT_CODE
diff --git a/src/include/commands/subscriptioncmds.h b/src/include/commands/subscriptioncmds.h
index fb4e26a51a4..255e1e241b8 100644
--- a/src/include/commands/subscriptioncmds.h
+++ b/src/include/commands/subscriptioncmds.h
@@ -17,6 +17,7 @@
#include "catalog/objectaddress.h"
#include "parser/parse_node.h"
+#include "replication/conflict.h"
extern ObjectAddress CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bool isTopLevel);
@@ -36,4 +37,8 @@ extern void CheckSubDeadTupleRetention(bool check_guc, bool sub_disabled,
bool retention_active,
bool max_retention_set);
+extern void GetConflictLogTableName(char *dest, Oid subid);
+extern ConflictLogDest GetLogDestination(const char *dest);
+extern bool IsConflictLogTable(Oid relid);
+
#endif /* SUBSCRIPTIONCMDS_H */
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index c8fbf9e51b8..70f8744b381 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -10,6 +10,7 @@
#define CONFLICT_H
#include "access/xlogdefs.h"
+#include "catalog/pg_type.h"
#include "nodes/pg_list.h"
#include "utils/timestamp.h"
@@ -79,6 +80,55 @@ typedef struct ConflictTupleInfo
* conflicting local row occurred */
} ConflictTupleInfo;
+/*
+ * Conflict log destination types.
+ *
+ * Internally we use these enum values for fast comparison, but we store
+ * the string equivalent in pg_subscription.sublogdestination.
+ */
+typedef enum ConflictLogDest
+{
+ CONFLICT_LOG_DEST_INVALID = 0,
+ CONFLICT_LOG_DEST_LOG, /* "log" (default) */
+ CONFLICT_LOG_DEST_TABLE, /* "table" */
+ CONFLICT_LOG_DEST_ALL /* "all" */
+} ConflictLogDest;
+
+/*
+ * Array mapping for converting internal enum to string.
+ */
+static const char *const ConflictLogDestLabels[] = {
+ [CONFLICT_LOG_DEST_LOG] = "log",
+ [CONFLICT_LOG_DEST_TABLE] = "table",
+ [CONFLICT_LOG_DEST_ALL] = "all"
+};
+
+/* Structure to hold metadata for one column of the conflict log table */
+typedef struct ConflictLogColumnDef
+{
+ const char *attname; /* Column name */
+ Oid atttypid; /* Data type OID */
+} ConflictLogColumnDef;
+
+/* The single source of truth for the conflict log table schema */
+static const ConflictLogColumnDef ConflictLogSchema[] =
+{
+ { .attname = "relid", .atttypid = OIDOID },
+ { .attname = "schemaname", .atttypid = TEXTOID },
+ { .attname = "relname", .atttypid = TEXTOID },
+ { .attname = "conflict_type", .atttypid = TEXTOID },
+ { .attname = "remote_xid", .atttypid = XIDOID },
+ { .attname = "remote_commit_lsn",.atttypid = LSNOID },
+ { .attname = "remote_commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "remote_origin", .atttypid = TEXTOID },
+ { .attname = "replica_identity", .atttypid = JSONOID },
+ { .attname = "remote_tuple", .atttypid = JSONOID },
+ { .attname = "local_conflicts", .atttypid = JSONARRAYOID }
+};
+
+/* Define the count using the array size */
+#define MAX_CONFLICT_ATTR_NUM (sizeof(ConflictLogSchema) / sizeof(ConflictLogSchema[0]))
+
extern bool GetTupleTransactionInfo(TupleTableSlot *localslot,
TransactionId *xmin,
RepOriginId *localorigin,
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index 327d1e7731f..a678471f4c2 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -116,18 +116,18 @@ CREATE SUBSCRIPTION regress_testsub4 CONNECTION 'dbname=regress_doesnotexist' PU
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub4 SET (origin = any);
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub3;
@@ -145,10 +145,10 @@ ALTER SUBSCRIPTION regress_testsub CONNECTION 'foobar';
ERROR: invalid connection string syntax: missing "=" after "foobar" in connection info string
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET PUBLICATION testpub2, testpub3 WITH (refresh = false);
@@ -157,10 +157,10 @@ ALTER SUBSCRIPTION regress_testsub SET (slot_name = 'newname');
ALTER SUBSCRIPTION regress_testsub SET (password_required = false);
ALTER SUBSCRIPTION regress_testsub SET (run_as_owner = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (password_required = true);
@@ -176,10 +176,10 @@ ERROR: unrecognized subscription parameter: "create_slot"
-- ok
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/12345');
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345 | log
(1 row)
-- ok - with lsn = NONE
@@ -188,10 +188,10 @@ ALTER SUBSCRIPTION regress_testsub SKIP (lsn = NONE);
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/0');
ERROR: invalid WAL location (LSN): 0/0
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
BEGIN;
@@ -223,10 +223,10 @@ ALTER SUBSCRIPTION regress_testsub_foo SET (synchronous_commit = foobar);
ERROR: invalid value for parameter "synchronous_commit": "foobar"
HINT: Available values: local, remote_write, remote_apply, on, off.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
----------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+---------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
-- rename back to keep the rest simple
@@ -255,19 +255,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (binary = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -279,27 +279,27 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = parallel);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- fail - publication already exists
@@ -314,10 +314,10 @@ ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refr
ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refresh = false);
ERROR: publication "testpub1" is already in subscription "regress_testsub"
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- fail - publication used more than once
@@ -332,10 +332,10 @@ ERROR: publication "testpub3" is not in subscription "regress_testsub"
-- ok - delete publications
ALTER SUBSCRIPTION regress_testsub DROP PUBLICATION testpub1, testpub2 WITH (refresh = false);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -371,19 +371,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- we can alter streaming when two_phase enabled
ALTER SUBSCRIPTION regress_testsub SET (streaming = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -393,10 +393,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -409,18 +409,18 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (disable_on_error = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -433,10 +433,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -450,19 +450,19 @@ NOTICE: max_retention_duration is ineffective when retain_dead_tuples is disabl
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- ok
ALTER SUBSCRIPTION regress_testsub SET (max_retention_duration = 0);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -517,7 +517,158 @@ COMMIT;
-- ok, owning it is enough for this stuff
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG DESTINATION TESTS
+--
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+-- fail - unrecognized format value
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
+ERROR: unrecognized conflict_log_destination value: "invalid"
+HINT: Valid values are "log", "table", and "all".
+-- verify sublogdestination is 'log' and relid is 0 (InvalidOid) for default case
+CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+SELECT subname, sublogdestination, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
+ subname | sublogdestination | subconflictlogrelid
+------------------------------+-------------------+---------------------
+ regress_conflict_log_default | log | 0
+(1 row)
+
+-- verify empty string defaults to 'log'
+CREATE SUBSCRIPTION regress_conflict_empty_str CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = '');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+SELECT subname, sublogdestination, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
+ subname | sublogdestination | subconflictlogrelid
+----------------------------+-------------------+---------------------
+ regress_conflict_empty_str | log | 0
+(1 row)
+
+-- this should generate an internal table named conflict_log_table_$subid$
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- check metadata in pg_subscription: destination should be 'table' and relid valid
+SELECT subname, sublogdestination, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname | sublogdestination | has_relid
+------------------------+-------------------+-----------
+ regress_conflict_test1 | table | t
+(1 row)
+
+-- verify the physical table exists and its OID matches subconflictlogrelid
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND c.oid = s.subconflictlogrelid;
+ count
+-------
+ 1
+(1 row)
+
+-- check if the internal table has the correct schema (11 columns)
+SELECT count(*)
+FROM pg_attribute a
+JOIN pg_class c ON a.attrelid = c.oid
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0;
+ count
+-------
+ 11
+(1 row)
+
+-- ALTER: State transitions
+-- transition from 'log' to 'all'
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
+-- verify metadata after ALTER (destination should be 'all')
+SELECT subname, sublogdestination, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | sublogdestination | has_relid
+------------------------+-------------------+-----------
+ regress_conflict_test2 | all | t
+(1 row)
+
+-- transition from 'all' to 'table' (should NOT drop the table, only change destination string)
+SELECT subconflictlogrelid AS old_relid FROM pg_subscription WHERE subname = 'regress_conflict_test2' \gset
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'table');
+SELECT sublogdestination, subconflictlogrelid = :old_relid AS relid_unchanged
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ sublogdestination | relid_unchanged
+-------------------+-----------------
+ table | t
+(1 row)
+
+-- transition from 'table' to 'log' (should drop the table and clear relid)
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log');
+SELECT sublogdestination, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ sublogdestination | subconflictlogrelid
+-------------------+---------------------
+ log | 0
+(1 row)
+
+-- verify the physical table is gone
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test2';
+ count
+-------
+ 0
+(1 row)
+
+-- ensure drop table not allowed and DROP SUBSCRIPTION reaps the table
+-- re-enable table logging for verification
+ALTER SUBSCRIPTION regress_conflict_test1 SET (conflict_log_destination = 'table');
+-- fail - drop table not allowed due to internal dependency
+-- use DO block to hide OID in error message
+DO $$
+BEGIN
+ EXECUTE 'DROP TABLE ' || (SELECT 'conflict_log_table_' || oid FROM pg_subscription WHERE subname = 'regress_conflict_test1');
+EXCEPTION WHEN dependent_objects_still_exist THEN
+ RAISE NOTICE 'captured expected error: dependent_objects_still_exist';
+END $$;
+NOTICE: captured expected error: dependent_objects_still_exist
+-- PUBLICATION: Verify internal tables are not publishable
+-- pg_relation_is_publishable should return false for internal conflict log tables
+SELECT pg_relation_is_publishable(subconflictlogrelid)
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ pg_relation_is_publishable
+----------------------------
+ f
+(1 row)
+
+-- CLEANUP: Proper drop reaps the table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+-- Verify the table OID for reap check
+SELECT 'conflict_log_table_' || oid AS internal_tablename FROM pg_subscription WHERE subname = 'regress_conflict_test1' \gset
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the internal table was reaped via dependency
+SELECT to_regclass(:'internal_tablename');
+ to_regclass
+-------------
+
+(1 row)
+
+ALTER SUBSCRIPTION regress_conflict_log_default DISABLE;
+ALTER SUBSCRIPTION regress_conflict_log_default SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_log_default;
+ALTER SUBSCRIPTION regress_conflict_empty_str DISABLE;
+ALTER SUBSCRIPTION regress_conflict_empty_str SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_empty_str;
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
RESET SESSION AUTHORIZATION;
+DROP SCHEMA clt;
+ERROR: schema "clt" does not exist
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
DROP ROLE regress_subscription_user3;
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index ef0c298d2df..df0e4649007 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -365,7 +365,115 @@ COMMIT;
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG DESTINATION TESTS
+--
+
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+
+-- fail - unrecognized format value
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
+
+-- verify sublogdestination is 'log' and relid is 0 (InvalidOid) for default case
+CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+SELECT subname, sublogdestination, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
+
+-- verify empty string defaults to 'log'
+CREATE SUBSCRIPTION regress_conflict_empty_str CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = '');
+SELECT subname, sublogdestination, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
+
+-- this should generate an internal table named conflict_log_table_$subid$
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+
+-- check metadata in pg_subscription: destination should be 'table' and relid valid
+SELECT subname, sublogdestination, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- verify the physical table exists and its OID matches subconflictlogrelid
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND c.oid = s.subconflictlogrelid;
+
+-- check if the internal table has the correct schema (11 columns)
+SELECT count(*)
+FROM pg_attribute a
+JOIN pg_class c ON a.attrelid = c.oid
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0;
+
+-- ALTER: State transitions
+-- transition from 'log' to 'all'
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
+
+-- verify metadata after ALTER (destination should be 'all')
+SELECT subname, sublogdestination, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'all' to 'table' (should NOT drop the table, only change destination string)
+SELECT subconflictlogrelid AS old_relid FROM pg_subscription WHERE subname = 'regress_conflict_test2' \gset
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'table');
+SELECT sublogdestination, subconflictlogrelid = :old_relid AS relid_unchanged
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'table' to 'log' (should drop the table and clear relid)
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log');
+SELECT sublogdestination, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- verify the physical table is gone
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test2';
+
+-- ensure drop table not allowed and DROP SUBSCRIPTION reaps the table
+-- re-enable table logging for verification
+ALTER SUBSCRIPTION regress_conflict_test1 SET (conflict_log_destination = 'table');
+
+-- fail - drop table not allowed due to internal dependency
+-- use DO block to hide OID in error message
+DO $$
+BEGIN
+ EXECUTE 'DROP TABLE ' || (SELECT 'conflict_log_table_' || oid FROM pg_subscription WHERE subname = 'regress_conflict_test1');
+EXCEPTION WHEN dependent_objects_still_exist THEN
+ RAISE NOTICE 'captured expected error: dependent_objects_still_exist';
+END $$;
+
+-- PUBLICATION: Verify internal tables are not publishable
+-- pg_relation_is_publishable should return false for internal conflict log tables
+SELECT pg_relation_is_publishable(subconflictlogrelid)
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- CLEANUP: Proper drop reaps the table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+
+-- Verify the table OID for reap check
+SELECT 'conflict_log_table_' || oid AS internal_tablename FROM pg_subscription WHERE subname = 'regress_conflict_test1' \gset
+
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the internal table was reaped via dependency
+SELECT to_regclass(:'internal_tablename');
+
+ALTER SUBSCRIPTION regress_conflict_log_default DISABLE;
+ALTER SUBSCRIPTION regress_conflict_log_default SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_log_default;
+
+ALTER SUBSCRIPTION regress_conflict_empty_str DISABLE;
+ALTER SUBSCRIPTION regress_conflict_empty_str SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_empty_str;
+
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+
RESET SESSION AUTHORIZATION;
+DROP SCHEMA clt;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
DROP ROLE regress_subscription_user3;
--
2.49.0
[application/octet-stream] v14-0002-Implement-the-conflict-insertion-infrastructure-.patch (27.0K, ../../CAFiTN-sNg9ghLNkB2Kn0SwBGOub9acc99XZZU_d5NAcyW-yrEg@mail.gmail.com/3-v14-0002-Implement-the-conflict-insertion-infrastructure-.patch)
download | inline diff:
From 9abb6eaf79f233db323f8ed65fd69e0651c11c54 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Sat, 20 Dec 2025 15:20:09 +0530
Subject: [PATCH v14 2/2] Implement the conflict insertion infrastructure into
the conflict log table
Note: A single remote tuple may conflict with multiple local tuples when conflict type
is CT_MULTIPLE_UNIQUE_CONFLICTS, so for handling this case we create a single row in
conflict log table with respect to each remote conflict tuple even if it conflicts with
multiple local tuples and we store the multiple conflict tuples as a single JSON array
element in format as
[ { "xid": "1001", "commit_ts": "...", "origin": "...", "tuple": {...} }, ... ]
We can extract the elements of the local tuples from the conflict log table row
as given in below example.
SELECT remote_xid, relname, remote_origin, local_conflicts[1] ->> 'xid' AS local_xid,
local_conflicts[1] ->> 'tuple' AS local_tuple
FROM myschema.conflict_log_history2;
remote_xid | relname | remote_origin | local_xid | local_tuple
------------+----------+---------------+-----------+---------------------
760 | test | pg_16406 | 771 | {"a":1,"b":10}
765 | conf_tab | pg_16406 | 775 | {"a":2,"b":2,"c":2}
---
src/backend/replication/logical/conflict.c | 619 +++++++++++++++++++--
src/backend/replication/logical/launcher.c | 1 +
src/backend/replication/logical/worker.c | 38 +-
src/include/replication/conflict.h | 3 +
src/include/replication/worker_internal.h | 7 +
5 files changed, 635 insertions(+), 33 deletions(-)
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 16695592265..5f753cd8042 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -15,13 +15,22 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/tableam.h"
+#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "pgstat.h"
#include "replication/conflict.h"
#include "replication/worker_internal.h"
#include "storage/lmgr.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/lsyscache.h"
+#include "utils/pg_lsn.h"
+#include "utils/jsonb.h"
+
+#define MAX_LOCAL_CONFLICT_INFO_ATTRS 5
static const char *const ConflictTypeNames[] = {
[CT_INSERT_EXISTS] = "insert_exists",
@@ -50,8 +59,27 @@ static char *build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *localslot,
TupleTableSlot *remoteslot,
Oid indexoid);
+static void build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull);
static char *build_index_value_desc(EState *estate, Relation localrel,
TupleTableSlot *slot, Oid indexoid);
+static Datum tuple_table_slot_to_json_datum(TupleTableSlot *slot);
+static Datum tuple_table_slot_to_indextup_json(EState *estate,
+ Relation localrel,
+ Oid replica_index,
+ TupleTableSlot *slot);
+static TupleDesc build_conflict_tupledesc(void);
+static Datum build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples);
+static void prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot);
/*
* Get the xmin and commit timestamp data (origin and timestamp) associated
@@ -105,11 +133,19 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
ConflictType type, TupleTableSlot *searchslot,
TupleTableSlot *remoteslot, List *conflicttuples)
{
- Relation localrel = relinfo->ri_RelationDesc;
- StringInfoData err_detail;
+ Relation localrel = relinfo->ri_RelationDesc;
+ StringInfoData err_detail;
+ ConflictLogDest dest;
+ Relation conflictlogrel;
initStringInfo(&err_detail);
+ /*
+ * Get both the conflict log destination and the opened conflict log
+ * relation for insertion.
+ */
+ conflictlogrel = GetConflictLogTableInfo(&dest);
+
/* Form errdetail message by combining conflicting tuples information. */
foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
errdetail_apply_conflict(estate, relinfo, type, searchslot,
@@ -120,15 +156,69 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
conflicttuple->ts,
&err_detail);
+ /* Insert to table if destination is 'table' or 'all' */
+ if (conflictlogrel)
+ {
+ Assert(dest == CONFLICT_LOG_DEST_TABLE ||
+ dest == CONFLICT_LOG_DEST_ALL);
+
+ if (ValidateConflictLogTable(conflictlogrel))
+ {
+ /*
+ * Prepare the conflict log tuple. If the error level is below
+ * ERROR, insert it immediately. Otherwise, defer the insertion to
+ * a new transaction after the current one aborts, ensuring the
+ * insertion of the log tuple is not rolled back.
+ */
+ prepare_conflict_log_tuple(estate,
+ relinfo->ri_RelationDesc,
+ conflictlogrel,
+ type,
+ searchslot,
+ conflicttuples,
+ remoteslot);
+ if (elevel < ERROR)
+ InsertConflictLogTuple(conflictlogrel);
+ }
+ else
+ ereport(WARNING,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("conflict log table \"%s.%s\" structure changed, skipping insertion",
+ get_namespace_name(RelationGetNamespace(conflictlogrel)),
+ RelationGetRelationName(conflictlogrel)));
+
+ table_close(conflictlogrel, RowExclusiveLock);
+ }
+
pgstat_report_subscription_conflict(MySubscription->oid, type);
- ereport(elevel,
- errcode_apply_conflict(type),
- errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
- get_namespace_name(RelationGetNamespace(localrel)),
- RelationGetRelationName(localrel),
- ConflictTypeNames[type]),
- errdetail_internal("%s", err_detail.data));
+ /* Decide what detail to show in server logs. */
+ if (dest == CONFLICT_LOG_DEST_LOG || dest == CONFLICT_LOG_DEST_ALL)
+ {
+ /* Standard reporting with full internal details. */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail_internal("%s", err_detail.data));
+ }
+ else
+ {
+ /*
+ * 'table' only: Report the error msg but omit raw tuple data from
+ * server logs since it's already captured in the internal table.
+ */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail("Conflict details logged to internal table with OID %u.",
+ MySubscription->conflictrelid));
+ }
}
/*
@@ -162,6 +252,142 @@ InitConflictIndexes(ResultRelInfo *relInfo)
relInfo->ri_onConflictArbiterIndexes = uniqueIndexes;
}
+/*
+ * GetConflictLogTableInfo
+ *
+ * Fetches conflict logging metadata from the cached MySubscription pointer.
+ * Sets the destination enum in *log_dest and, if applicable, opens and
+ * returns the relation handle for the internal log table.
+ */
+Relation
+GetConflictLogTableInfo(ConflictLogDest *log_dest)
+{
+ Oid conflictlogrelid;
+ Relation conflictlogrel = NULL;
+
+ /*
+ * Convert the text log destination to the internal enum. MySubscription
+ * already contains the data from pg_subscription.
+ */
+ *log_dest = GetLogDestination(MySubscription->logdestination);
+ conflictlogrelid = MySubscription->conflictrelid;
+
+ /* If destination is 'log' only, no table to open. */
+ if (*log_dest == CONFLICT_LOG_DEST_LOG)
+ return NULL;
+
+ Assert(OidIsValid(conflictlogrelid));
+
+ conflictlogrel = table_open(conflictlogrelid, RowExclusiveLock);
+
+ /* Conflict log table is dropped or not accessible. */
+ if (conflictlogrel == NULL)
+ ereport(WARNING,
+ (errcode(ERRCODE_UNDEFINED_TABLE),
+ errmsg("conflict log table with OID %u does not exist",
+ conflictlogrelid)));
+
+ return conflictlogrel;
+}
+
+/*
+ * InsertConflictLogTuple
+ *
+ * Insert conflict log tuple into the conflict log table. It uses
+ * HEAP_INSERT_NO_LOGICAL to explicitly block logical decoding of the tuple
+ * inserted into the conflict log table.
+ */
+void
+InsertConflictLogTuple(Relation conflictlogrel)
+{
+ int options = HEAP_INSERT_NO_LOGICAL;
+
+ /* A valid tuple must be prepared and stored in MyLogicalRepWorker. */
+ Assert(MyLogicalRepWorker->conflict_log_tuple != NULL);
+
+ heap_insert(conflictlogrel, MyLogicalRepWorker->conflict_log_tuple,
+ GetCurrentCommandId(true), options, NULL);
+
+ /* Free conflict log tuple. */
+ heap_freetuple(MyLogicalRepWorker->conflict_log_tuple);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+}
+
+/*
+ * ValidateConflictLogTable - Validate conflict log table
+ *
+ * Validate whether the conflict log table is still suitable for considering as
+ * conflict log table.
+ */
+bool
+ValidateConflictLogTable(Relation rel)
+{
+ Relation pg_attribute;
+ HeapTuple atup;
+ ScanKeyData scankey;
+ SysScanDesc scan;
+ Form_pg_attribute attForm;
+ int attcnt = 0;
+ bool tbl_ok = true;
+
+ /*
+ * Check whether the table definition including its column names, data
+ * types, and column ordering meets the requirements for conflict log
+ * table.
+ */
+ pg_attribute = table_open(AttributeRelationId, AccessShareLock);
+ ScanKeyInit(&scankey,
+ Anum_pg_attribute_attrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationGetRelid(rel)));
+
+ scan = systable_beginscan(pg_attribute, AttributeRelidNumIndexId, true,
+ SnapshotSelf, 1, &scankey);
+
+ /* We only need to check up to MAX_CONFLICT_ATTR_NUM attributes */
+ while (HeapTupleIsValid(atup = systable_getnext(scan)))
+ {
+ const ConflictLogColumnDef *expected;
+ int schema_idx;
+
+ attForm = (Form_pg_attribute) GETSTRUCT(atup);
+
+ /* Skip system columns and dropped columns */
+ if (attForm->attnum < 1 || attForm->attisdropped)
+ continue;
+
+ attcnt++;
+
+ /* attnum 1 corresponds to index 0 in ConflictLogSchema */
+ schema_idx = attForm->attnum - 1;
+
+ /* Check against the central schema definition */
+ if (schema_idx >= MAX_CONFLICT_ATTR_NUM)
+ {
+ /* Found an extra column beyond the required set */
+ tbl_ok = false;
+ break;
+ }
+
+ expected = &ConflictLogSchema[schema_idx];
+
+ if (attForm->atttypid != expected->atttypid ||
+ strcmp(NameStr(attForm->attname), expected->attname) != 0)
+ {
+ tbl_ok = false;
+ break;
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_attribute, AccessShareLock);
+
+ if (attcnt != MAX_CONFLICT_ATTR_NUM || !tbl_ok)
+ return false;
+
+ return true;
+}
+
/*
* Add SQLSTATE error code to the current conflict report.
*/
@@ -472,6 +698,40 @@ build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
return tuple_value.data;
}
+/*
+ * Helper function to extract the "raw" index key Datums and their null flags
+ * from a TupleTableSlot, given an already open index descriptor.
+ * This is the reusable core logic.
+ */
+static void
+build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull)
+{
+ TupleTableSlot *tableslot = slot;
+
+ /*
+ * If the slot is a virtual slot, copy it into a heap tuple slot as
+ * FormIndexDatum only works with heap tuple slots.
+ */
+ if (TTS_IS_VIRTUAL(slot))
+ {
+ /* Slot is created within the EState's tuple table */
+ tableslot = table_slot_create(localrel, &estate->es_tupleTable);
+ tableslot = ExecCopySlot(tableslot, slot);
+ }
+
+ /*
+ * Initialize ecxt_scantuple for potential use in FormIndexDatum
+ */
+ GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+
+ /* Form the index datums */
+ FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values,
+ isnull);
+}
+
/*
* Helper functions to construct a string describing the contents of an index
* entry. See BuildIndexValueDescription for details.
@@ -487,41 +747,336 @@ build_index_value_desc(EState *estate, Relation localrel, TupleTableSlot *slot,
Relation indexDesc;
Datum values[INDEX_MAX_KEYS];
bool isnull[INDEX_MAX_KEYS];
- TupleTableSlot *tableslot = slot;
- if (!tableslot)
+ if (!slot)
return NULL;
Assert(CheckRelationOidLockedByMe(indexoid, RowExclusiveLock, true));
indexDesc = index_open(indexoid, NoLock);
- /*
- * If the slot is a virtual slot, copy it into a heap tuple slot as
- * FormIndexDatum only works with heap tuple slots.
- */
- if (TTS_IS_VIRTUAL(slot))
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+
+ index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+
+ index_close(indexDesc, NoLock);
+
+ return index_value;
+}
+
+/*
+ * tuple_table_slot_to_json_datum
+ *
+ * Helper function to convert a TupleTableSlot to Jsonb.
+ */
+static Datum
+tuple_table_slot_to_json_datum(TupleTableSlot *slot)
+{
+ HeapTuple tuple;
+ Datum datum;
+ Datum json;
+
+ Assert(slot != NULL);
+
+ tuple = ExecCopySlotHeapTuple(slot);
+ datum = heap_copy_tuple_as_datum(tuple, slot->tts_tupleDescriptor);
+
+ json = DirectFunctionCall1(row_to_json, datum);
+ heap_freetuple(tuple);
+
+ return json;
+}
+
+/*
+ * tuple_table_slot_to_indextup_json
+ *
+ * Fetch replica identity key from the tuple table slot and convert into a
+ * jsonb datum.
+ */
+static Datum
+tuple_table_slot_to_indextup_json(EState *estate, Relation localrel,
+ Oid indexid, TupleTableSlot *slot)
+{
+ Relation indexDesc;
+ Datum values[INDEX_MAX_KEYS];
+ bool isnull[INDEX_MAX_KEYS];
+ HeapTuple tuple;
+ TupleDesc tupdesc;
+ Datum datum;
+
+ Assert(slot != NULL);
+
+ Assert(CheckRelationOidLockedByMe(indexid, RowExclusiveLock, true));
+
+ indexDesc = index_open(indexid, NoLock);
+
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+ tupdesc = RelationGetDescr(indexDesc);
+
+ /* Bless the tupdesc so it can be looked up by row_to_json. */
+ BlessTupleDesc(tupdesc);
+
+ /* Form the replica identity tuple. */
+ tuple = heap_form_tuple(tupdesc, values, isnull);
+ datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ index_close(indexDesc, NoLock);
+
+ /* Convert to a JSONB datum. */
+ return DirectFunctionCall1(row_to_json, datum);
+}
+
+/*
+ * Initialize the tuple descriptor for local conflict info.
+ */
+static TupleDesc
+build_conflict_tupledesc(void)
+{
+ TupleDesc tupdesc;
+ int attno = 1;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ TupleDescInitEntry(tupdesc, (AttrNumber) attno++, "xid",
+ XIDOID, -1, 0);
+ TupleDescInitEntry(tupdesc, (AttrNumber) attno++, "commit_ts",
+ TIMESTAMPTZOID, -1, 0);
+ TupleDescInitEntry(tupdesc, (AttrNumber) attno++, "origin",
+ TEXTOID, -1, 0);
+ TupleDescInitEntry(tupdesc, (AttrNumber) attno++, "key",
+ JSONOID, -1, 0);
+ TupleDescInitEntry(tupdesc, (AttrNumber) attno, "tuple",
+ JSONOID, -1, 0);
+
+ BlessTupleDesc(tupdesc);
+
+ Assert(attno == MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ return tupdesc;
+}
+
+/*
+ * Builds the local conflicts JSONB array column from the list of
+ * ConflictTupleInfo objects.
+ *
+ * Example output structure:
+ * [ { "xid": "1001", "commit_ts": "...", "origin": "...", "tuple": {...} }, ... ]
+ */
+static Datum
+build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples)
+{
+ ListCell *lc;
+ List *json_datums = NIL; /* List to hold the row_to_json results (type json) */
+ Datum *json_datum_array;
+ bool *json_null_array;
+ Datum json_array_datum;
+ int num_conflicts;
+ int i;
+ int16 typlen;
+ bool typbyval;
+ char typalign;
+ TupleDesc tupdesc;
+
+ /* Build local conflicts tuple descriptor. */
+ tupdesc = build_conflict_tupledesc();
+
+ /* Process local conflict tuple list and prepare an array of JSON. */
+ foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
{
- tableslot = table_slot_create(localrel, &estate->es_tupleTable);
- tableslot = ExecCopySlot(tableslot, slot);
+ Datum values[MAX_LOCAL_CONFLICT_INFO_ATTRS];
+ bool nulls[MAX_LOCAL_CONFLICT_INFO_ATTRS];
+ char *origin_name = NULL;
+ HeapTuple tuple;
+ Datum json_datum;
+ int attno;
+
+ memset(values, 0, sizeof(Datum) * MAX_LOCAL_CONFLICT_INFO_ATTRS);
+ memset(nulls, 0, sizeof(bool) * MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ attno = 0;
+ values[attno++] = TransactionIdGetDatum(conflicttuple->xmin);
+
+ if (conflicttuple->ts)
+ values[attno++] = TimestampTzGetDatum(conflicttuple->ts);
+ else
+ nulls[attno++] = true;
+
+ if (conflicttuple->origin != InvalidRepOriginId)
+ replorigin_by_oid(conflicttuple->origin, true, &origin_name);
+
+ /* Store empty string if origin name for the tuple is NULL. */
+ if (origin_name != NULL)
+ values[attno++] = CStringGetTextDatum(origin_name);
+ else
+ nulls[attno++] = true;
+
+ /*
+ * Add the conflicting key values in the case of a unique constraint
+ * violation.
+ */
+ if (conflict_type == CT_INSERT_EXISTS ||
+ conflict_type == CT_UPDATE_EXISTS ||
+ conflict_type == CT_MULTIPLE_UNIQUE_CONFLICTS)
+ {
+ Oid indexoid = conflicttuple->indexoid;
+
+ Assert(OidIsValid(indexoid) && conflicttuple->slot &&
+ CheckRelationOidLockedByMe(indexoid, RowExclusiveLock,
+ true));
+ values[attno++] =
+ tuple_table_slot_to_indextup_json(estate, rel,
+ indexoid,
+ conflicttuple->slot);
+ }
+ else
+ nulls[attno++] = true;
+
+ /* Convert conflicting tuple to JSON datum. */
+ if (conflicttuple->slot)
+ values[attno] = tuple_table_slot_to_json_datum(conflicttuple->slot);
+ else
+ nulls[attno] = true;
+
+ Assert(attno + 1 == MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ tuple = heap_form_tuple(tupdesc, values, nulls);
+
+ json_datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ /*
+ * Build the higher level JSON datum in format described in function
+ * header.
+ */
+ json_datum = DirectFunctionCall1(row_to_json, json_datum);
+
+ /* Done with the temporary tuple. */
+ heap_freetuple(tuple);
+
+ /* Add to the array element. */
+ json_datums = lappend(json_datums, (void *) json_datum);
}
- /*
- * Initialize ecxt_scantuple for potential use in FormIndexDatum when
- * index expressions are present.
- */
- GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+ num_conflicts = list_length(json_datums);
- /*
- * The values/nulls arrays passed to BuildIndexValueDescription should be
- * the results of FormIndexDatum, which are the "raw" input to the index
- * AM.
- */
- FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values, isnull);
+ json_datum_array = (Datum *) palloc(num_conflicts * sizeof(Datum));
+ json_null_array = (bool *) palloc0(num_conflicts * sizeof(bool));
- index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+ i = 0;
+ foreach(lc, json_datums)
+ {
+ json_datum_array[i] = (Datum) lfirst(lc);
+ i++;
+ }
- index_close(indexDesc, NoLock);
+ /* Construct the json[] array Datum. */
+ get_typlenbyvalalign(JSONOID, &typlen, &typbyval, &typalign);
+ json_array_datum = PointerGetDatum(construct_array(json_datum_array,
+ num_conflicts,
+ JSONOID,
+ typlen,
+ typbyval,
+ typalign));
+ pfree(json_datum_array);
+ pfree(json_null_array);
+
+ return json_array_datum;
+}
- return index_value;
+/*
+ * prepare_conflict_log_tuple
+ *
+ * This routine prepares a tuple detailing a conflict encountered during
+ * logical replication. The prepared tuple will be stored in
+ * MyLogicalRepWorker->conflict_log_tuple which should be inserted into the
+ * conflict log table by calling InsertConflictLogTuple.
+ */
+static void
+prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot)
+{
+ Datum values[MAX_CONFLICT_ATTR_NUM];
+ bool nulls[MAX_CONFLICT_ATTR_NUM];
+ int attno;
+ char *remote_origin = NULL;
+ MemoryContext oldctx;
+
+ Assert(MyLogicalRepWorker->conflict_log_tuple == NULL);
+
+ /* Initialize values and nulls arrays. */
+ memset(values, 0, sizeof(Datum) * MAX_CONFLICT_ATTR_NUM);
+ memset(nulls, 0, sizeof(bool) * MAX_CONFLICT_ATTR_NUM);
+
+ /* Populate the values and nulls arrays. */
+ attno = 0;
+ values[attno++] = ObjectIdGetDatum(RelationGetRelid(rel));
+
+ values[attno++] =
+ CStringGetTextDatum(get_namespace_name(RelationGetNamespace(rel)));
+
+ values[attno++] = CStringGetTextDatum(RelationGetRelationName(rel));
+
+ values[attno++] = CStringGetTextDatum(ConflictTypeNames[conflict_type]);
+
+ if (TransactionIdIsValid(remote_xid))
+ values[attno++] = TransactionIdGetDatum(remote_xid);
+ else
+ nulls[attno++] = true;
+
+ values[attno++] = LSNGetDatum(remote_final_lsn);
+
+ if (remote_commit_ts > 0)
+ values[attno++] = TimestampTzGetDatum(remote_commit_ts);
+ else
+ nulls[attno++] = true;
+
+ if (replorigin_session_origin != InvalidRepOriginId)
+ replorigin_by_oid(replorigin_session_origin, true, &remote_origin);
+
+ if (remote_origin != NULL)
+ values[attno++] = CStringGetTextDatum(remote_origin);
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(searchslot))
+ {
+ Oid replica_index = GetRelationIdentityOrPK(rel);
+
+ /*
+ * If the table has a valid replica identity index, build the index
+ * json datum from key value. Otherwise, construct it from the complete
+ * tuple in REPLICA IDENTITY FULL cases.
+ */
+ if (OidIsValid(replica_index))
+ values[attno++] = tuple_table_slot_to_indextup_json(estate, rel,
+ replica_index,
+ searchslot);
+ else
+ values[attno++] = tuple_table_slot_to_json_datum(searchslot);
+ }
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(remoteslot))
+ values[attno++] = tuple_table_slot_to_json_datum(remoteslot);
+ else
+ nulls[attno++] = true;
+
+ values[attno] = build_local_conflicts_json_array(estate, rel,
+ conflict_type,
+ conflicttuples);
+
+ Assert(attno + 1 == MAX_CONFLICT_ATTR_NUM);
+
+ oldctx = MemoryContextSwitchTo(ApplyContext);
+ MyLogicalRepWorker->conflict_log_tuple =
+ heap_form_tuple(RelationGetDescr(conflictlogrel), values, nulls);
+ MemoryContextSwitchTo(oldctx);
}
diff --git a/src/backend/replication/logical/launcher.c b/src/backend/replication/logical/launcher.c
index 3991e1495d4..bc7e1d9ebde 100644
--- a/src/backend/replication/logical/launcher.c
+++ b/src/backend/replication/logical/launcher.c
@@ -477,6 +477,7 @@ retry:
worker->oldest_nonremovable_xid = retain_dead_tuples
? MyReplicationSlot->data.xmin
: InvalidTransactionId;
+ worker->conflict_log_tuple = NULL;
worker->last_lsn = InvalidXLogRecPtr;
TIMESTAMP_NOBEGIN(worker->last_send_time);
TIMESTAMP_NOBEGIN(worker->last_recv_time);
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index fc64476a9ef..ccd1b2c6e81 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -482,7 +482,9 @@ static bool MySubscriptionValid = false;
static List *on_commit_wakeup_workers_subids = NIL;
bool in_remote_transaction = false;
-static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+TransactionId remote_xid = InvalidTransactionId;
+TimestampTz remote_commit_ts = 0;
/* fields valid only when processing streamed transaction */
static bool in_streamed_transaction = false;
@@ -1219,6 +1221,8 @@ apply_handle_begin(StringInfo s)
set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
remote_final_lsn = begin_data.final_lsn;
+ remote_commit_ts = begin_data.committime;
+ remote_xid = begin_data.xid;
maybe_start_skipping_changes(begin_data.final_lsn);
@@ -1745,6 +1749,10 @@ apply_handle_stream_start(StringInfo s)
/* extract XID of the top-level transaction */
stream_xid = logicalrep_read_stream_start(s, &first_segment);
+ remote_xid = stream_xid;
+ remote_final_lsn = InvalidXLogRecPtr;
+ remote_commit_ts = 0;
+
if (!TransactionIdIsValid(stream_xid))
ereport(ERROR,
(errcode(ERRCODE_PROTOCOL_VIOLATION),
@@ -5609,6 +5617,34 @@ start_apply(XLogRecPtr origin_startpos)
pgstat_report_subscription_error(MySubscription->oid,
MyLogicalRepWorker->type);
+ /*
+ * Insert any pending conflict log tuple under a new transaction.
+ */
+ if (MyLogicalRepWorker->conflict_log_tuple != NULL)
+ {
+ Relation conflictlogrel;
+ ConflictLogDest dest;
+
+ StartTransactionCommand();
+ PushActiveSnapshot(GetTransactionSnapshot());
+
+ /* Open conflict log table and insert the tuple. */
+ conflictlogrel = GetConflictLogTableInfo(&dest);
+ if (ValidateConflictLogTable(conflictlogrel))
+ InsertConflictLogTuple(conflictlogrel);
+ else
+ ereport(WARNING,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("Conflict log table \"%s.%s\" structure changed, skipping insertion",
+ get_namespace_name(RelationGetNamespace(conflictlogrel)),
+ RelationGetRelationName(conflictlogrel)));
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+ table_close(conflictlogrel, RowExclusiveLock);
+
+ PopActiveSnapshot();
+ CommitTransactionCommand();
+ }
+
PG_RE_THROW();
}
}
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index 70f8744b381..226fed9e383 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -139,4 +139,7 @@ extern void ReportApplyConflict(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *remoteslot,
List *conflicttuples);
extern void InitConflictIndexes(ResultRelInfo *relInfo);
+extern Relation GetConflictLogTableInfo(ConflictLogDest *log_dest);
+extern void InsertConflictLogTuple(Relation conflictlogrel);
+extern bool ValidateConflictLogTable(Relation rel);
#endif
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index f081619f151..711c04c7297 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -101,6 +101,9 @@ typedef struct LogicalRepWorker
*/
TransactionId oldest_nonremovable_xid;
+ /* A conflict log tuple that is prepared but not yet inserted. */
+ HeapTuple conflict_log_tuple;
+
/* Stats. */
XLogRecPtr last_lsn;
TimestampTz last_send_time;
@@ -256,6 +259,10 @@ extern PGDLLIMPORT bool InitializingApplyWorker;
extern PGDLLIMPORT List *table_states_not_ready;
+extern XLogRecPtr remote_final_lsn;
+extern TimestampTz remote_commit_ts;
+extern TransactionId remote_xid;
+
extern void logicalrep_worker_attach(int slot);
extern LogicalRepWorker *logicalrep_worker_find(LogicalRepWorkerType wtype,
Oid subid, Oid relid,
--
2.49.0
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-21 15:46 vignesh C <vignesh21@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
5 siblings, 0 replies; 912+ messages in thread
From: vignesh C @ 2025-12-21 15:46 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Sat, 20 Dec 2025 at 16:51, Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> I have updated the patch and here are changes done
> 1. Splitted into 2 patches, 0001- for catalog related changes
> 0002-inserting conflict into the conflict table, Vignesh need to
> rebase the dump and upgrade related patch on this latest changes
> 2. Subscription option changed to conflict_log_destination=(log/table/all/'')
> 3. For internal processing we will use ConflictLogDest enum whereas
> for taking input or storing into catalog we will use string [1].
> 4. As suggested by Sawada San, if conflict_log_destination is 'table'
> we log the information about conflict but don't log the tuple
> details[3]
>
> Pending:
> 2. Still need to work on caching related changes discussed at [2], so
> currently we don't allow conflict log tables to be added to
> publication at all and might change this behavior as discussed at [2]
> and for that we will need to implement the caching.
This point is addressed in the attached patch. A new shared index on
pg_subscription (subconflictlogrelid) is introduced and used to
efficiently determine whether a relation is a conflict log table,
avoiding full catalog scans. Additionally, a conflict log table can be
explicitly added to a TABLE publication and will be published when
specified directly. At the same time, such relations are excluded from
implicit publication paths (FOR ALL TABLES and schema publications).
The patch also exposes pg_relation_is_conflict_log_table() as a
SQL-visible helper, which is used by psql \d+ to filter out conflict
log tables from implicit publication listings. This avoids querying
pg_subscription directly, which is generally inaccessible to
non-superusers.
These changes are included in v14-003. There are no changes in v14-001
and v14-002; those versions are identical to the patch previously
shared by Dilip at [1].
[1] - https://www.postgresql.org/message-id/CAFiTN-sNg9ghLNkB2Kn0SwBGOub9acc99XZZU_d5NAcyW-yrEg%40mail.gma...
Regards,
Vignesh
Attachments:
[text/x-patch] v14-0001-Add-configurable-conflict-log-table-for-Logical-.patch (97.0K, ../../CALDaNm17TRArpX3Axy=2AQBSFbuf-E5Hj0+rNYcMSfXLxenmJQ@mail.gmail.com/2-v14-0001-Add-configurable-conflict-log-table-for-Logical-.patch)
download | inline diff:
From 87f5f424ee553d96b988ba0b5f7e947796f85c2c Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 17 Dec 2025 11:53:47 +0530
Subject: [PATCH v14 1/3] Add configurable conflict log table for Logical
Replication
This patch adds a feature to provide a structured, queryable record of all
logical replication conflicts. The current approach of logging conflicts as
plain text in the server logs makes it difficult to query, analyze, and
use for external monitoring and automation.
This patch addresses these limitations by introducing a configurable
conflict_log_destination=(log/table/all) option in the CREATE SUBSCRIPTION
command.
If user choose to log into the table the table will automatically created while
creating the subscription with internal name i.e. conflict_log_table_$subid$. The
table will be created in the current search path and table would be automatically
dropped while dropping the subscription.
The conflict details, including the original and remote tuples, are stored in JSON
columns, providing a flexible format to accommodate different table schemas.
The log table captures essential attributes such as local and remote transaction IDs,
LSNs, commit timestamps, and conflict type, providing a complete record for post-mortem
analysis.
This feature will make logical replication conflicts easier to monitor and manage,
significantly improving the overall resilience and operability of replication setups.
The conflict log tables will not be included in a publication, even if the publication
is configured to include ALL TABLES or ALL TABLES IN SCHEMA.
---
src/backend/catalog/pg_publication.c | 26 +-
src/backend/catalog/pg_subscription.c | 7 +
src/backend/commands/subscriptioncmds.c | 333 ++++++++++++++++++++-
src/bin/psql/describe.c | 21 +-
src/bin/psql/tab-complete.in.c | 8 +-
src/include/catalog/pg_subscription.h | 11 +
src/include/commands/subscriptioncmds.h | 5 +
src/include/replication/conflict.h | 50 ++++
src/test/regress/expected/subscription.out | 327 ++++++++++++++------
src/test/regress/sql/subscription.sql | 108 +++++++
10 files changed, 792 insertions(+), 104 deletions(-)
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index 7aa3f179924..9f84e02b7ef 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -31,6 +31,7 @@
#include "catalog/pg_publication_rel.h"
#include "catalog/pg_type.h"
#include "commands/publicationcmds.h"
+#include "commands/subscriptioncmds.h"
#include "funcapi.h"
#include "utils/array.h"
#include "utils/builtins.h"
@@ -85,6 +86,15 @@ check_publication_add_relation(Relation targetrel)
errmsg("cannot add relation \"%s\" to publication",
RelationGetRelationName(targetrel)),
errdetail("This operation is not supported for unlogged tables.")));
+
+ /* Can't be conflict log table */
+ if (IsConflictLogTable(RelationGetRelid(targetrel)))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot add relation \"%s.%s\" to publication",
+ get_namespace_name(RelationGetNamespace(targetrel)),
+ RelationGetRelationName(targetrel)),
+ errdetail("This operation is not supported for conflict log tables.")));
}
/*
@@ -145,6 +155,13 @@ is_publishable_class(Oid relid, Form_pg_class reltuple)
/*
* Another variant of is_publishable_class(), taking a Relation.
+ *
+ * Note: Conflict log tables are not publishable. However, we intentionally
+ * skip this check here because this function is called for every change and
+ * performing this check during every change publication is costly. To ensure
+ * unpublishable entries are ignored without incurring performance overhead,
+ * tuples inserted into the conflict log table uses the HEAP_INSERT_NO_LOGICAL
+ * flag. This allows the decoding layer to bypass these entries automatically.
*/
bool
is_publishable_relation(Relation rel)
@@ -169,7 +186,10 @@ pg_relation_is_publishable(PG_FUNCTION_ARGS)
tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
if (!HeapTupleIsValid(tuple))
PG_RETURN_NULL();
- result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple));
+
+ /* Subscription conflict log tables are not published */
+ result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple)) &&
+ !IsConflictLogTable(relid);
ReleaseSysCache(tuple);
PG_RETURN_BOOL(result);
}
@@ -890,7 +910,9 @@ GetAllPublicationRelations(char relkind, bool pubviaroot)
Form_pg_class relForm = (Form_pg_class) GETSTRUCT(tuple);
Oid relid = relForm->oid;
+ /* Subscription conflict log tables are not published */
if (is_publishable_class(relid, relForm) &&
+ !IsConflictLogTable(relid) &&
!(relForm->relispartition && pubviaroot))
result = lappend_oid(result, relid);
}
@@ -1018,7 +1040,7 @@ GetSchemaPublicationRelations(Oid schemaid, PublicationPartOpt pub_partopt)
Oid relid = relForm->oid;
char relkind;
- if (!is_publishable_class(relid, relForm))
+ if (!is_publishable_class(relid, relForm) || IsConflictLogTable(relid))
continue;
relkind = get_rel_relkind(relid);
diff --git a/src/backend/catalog/pg_subscription.c b/src/backend/catalog/pg_subscription.c
index ad6fbd77ffd..27a9aee1c56 100644
--- a/src/backend/catalog/pg_subscription.c
+++ b/src/backend/catalog/pg_subscription.c
@@ -106,6 +106,7 @@ GetSubscription(Oid subid, bool missing_ok)
sub->retaindeadtuples = subform->subretaindeadtuples;
sub->maxretention = subform->submaxretention;
sub->retentionactive = subform->subretentionactive;
+ sub->conflictrelid = subform->subconflictlogrelid;
/* Get conninfo */
datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
@@ -141,6 +142,12 @@ GetSubscription(Oid subid, bool missing_ok)
Anum_pg_subscription_suborigin);
sub->origin = TextDatumGetCString(datum);
+ /* Get conflict log destination */
+ datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
+ tup,
+ Anum_pg_subscription_sublogdestination);
+ sub->logdestination = TextDatumGetCString(datum);
+
/* Is the subscription owner a superuser? */
sub->ownersuperuser = superuser_arg(sub->owner);
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index abbcaff0838..65c4c0dd8e4 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -15,16 +15,19 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/htup_details.h"
#include "access/table.h"
#include "access/twophase.h"
#include "access/xact.h"
#include "catalog/catalog.h"
#include "catalog/dependency.h"
+#include "catalog/heap.h"
#include "catalog/indexing.h"
#include "catalog/namespace.h"
#include "catalog/objectaccess.h"
#include "catalog/objectaddress.h"
+#include "catalog/pg_am_d.h"
#include "catalog/pg_authid_d.h"
#include "catalog/pg_database_d.h"
#include "catalog/pg_subscription.h"
@@ -34,6 +37,7 @@
#include "commands/event_trigger.h"
#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "miscadmin.h"
#include "nodes/makefuncs.h"
#include "pgstat.h"
@@ -51,6 +55,7 @@
#include "utils/lsyscache.h"
#include "utils/memutils.h"
#include "utils/pg_lsn.h"
+#include "utils/regproc.h"
#include "utils/syscache.h"
/*
@@ -75,6 +80,7 @@
#define SUBOPT_MAX_RETENTION_DURATION 0x00008000
#define SUBOPT_LSN 0x00010000
#define SUBOPT_ORIGIN 0x00020000
+#define SUBOPT_CONFLICT_LOG_DESTINATION 0x00040000
/* check if the 'val' has 'bits' set */
#define IsSet(val, bits) (((val) & (bits)) == (bits))
@@ -103,6 +109,7 @@ typedef struct SubOpts
bool retaindeadtuples;
int32 maxretention;
char *origin;
+ ConflictLogDest logdest;
XLogRecPtr lsn;
} SubOpts;
@@ -135,7 +142,8 @@ static List *merge_publications(List *oldpublist, List *newpublist, bool addpub,
static void ReportSlotConnectionError(List *rstates, Oid subid, char *slotname, char *err);
static void CheckAlterSubOption(Subscription *sub, const char *option,
bool slot_needs_update, bool isTopLevel);
-
+static Oid create_conflict_log_table(Oid namespaceId, char *conflictrel,
+ Oid subid);
/*
* Common option parsing function for CREATE and ALTER SUBSCRIPTION commands.
@@ -191,6 +199,8 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->maxretention = 0;
if (IsSet(supported_opts, SUBOPT_ORIGIN))
opts->origin = pstrdup(LOGICALREP_ORIGIN_ANY);
+ if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DESTINATION))
+ opts->logdest = CONFLICT_LOG_DEST_LOG;
/* Parse options */
foreach(lc, stmt_options)
@@ -402,6 +412,28 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->specified_opts |= SUBOPT_LSN;
opts->lsn = lsn;
}
+ else if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DESTINATION) &&
+ strcmp(defel->defname, "conflict_log_destination") == 0)
+ {
+ char *val;
+ ConflictLogDest dest;
+
+ if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_LOG_DESTINATION))
+ errorConflictingDefElem(defel, pstate);
+
+ val = defGetString(defel);
+
+ dest = GetLogDestination(val);
+
+ if (dest == CONFLICT_LOG_DEST_INVALID)
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("unrecognized conflict_log_destination value: \"%s\"", val),
+ errhint("Valid values are \"log\", \"table\", and \"all\".")));
+
+ opts->logdest = dest;
+ opts->specified_opts |= SUBOPT_CONFLICT_LOG_DESTINATION;
+ }
else
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
@@ -612,7 +644,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
SUBOPT_DISABLE_ON_ERR | SUBOPT_PASSWORD_REQUIRED |
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
- SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN);
+ SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_DESTINATION);
parse_subscription_options(pstate, stmt->options, supported_opts, &opts);
/*
@@ -747,6 +780,40 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
values[Anum_pg_subscription_suborigin - 1] =
CStringGetTextDatum(opts.origin);
+ /* Always set the destination, default will be log. */
+ values[Anum_pg_subscription_sublogdestination - 1] =
+ CStringGetTextDatum(ConflictLogDestLabels[opts.logdest]);
+
+ /*
+ * If the conflict log destination includes 'table', generate an internal
+ * name using the subscription OID and determine the target namespace based
+ * on the current search path. Store the namespace OID and the conflict log
+ * format in the pg_subscription catalog tuple., then physically create
+ * the table.
+ */
+ if (opts.logdest == CONFLICT_LOG_DEST_TABLE ||
+ opts.logdest == CONFLICT_LOG_DEST_ALL)
+ {
+ char conflict_table_name[NAMEDATALEN];
+ Oid namespaceId, logrelid;
+
+ GetConflictLogTableName(conflict_table_name, subid);
+ namespaceId = RangeVarGetCreationNamespace(
+ makeRangeVar(NULL, conflict_table_name, -1));
+
+ /* Store the Oid returned from creation. */
+ logrelid = create_conflict_log_table(namespaceId, conflict_table_name,
+ subid);
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(logrelid);
+ }
+ else
+ {
+ /* Destination is "log"; no table is needed. */
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(InvalidOid);
+ }
+
tup = heap_form_tuple(RelationGetDescr(rel), values, nulls);
/* Insert tuple into catalog. */
@@ -1410,7 +1477,8 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
SUBOPT_MAX_RETENTION_DURATION |
- SUBOPT_ORIGIN);
+ SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_DESTINATION);
parse_subscription_options(pstate, stmt->options,
supported_opts, &opts);
@@ -1665,6 +1733,73 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
origin = opts.origin;
}
+ if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_LOG_DESTINATION))
+ {
+ ConflictLogDest old_dest =
+ GetLogDestination(sub->logdestination);
+
+ if (opts.logdest != old_dest)
+ {
+ bool want_table =
+ (opts.logdest == CONFLICT_LOG_DEST_TABLE ||
+ opts.logdest == CONFLICT_LOG_DEST_ALL);
+ bool has_oldtable =
+ (old_dest == CONFLICT_LOG_DEST_TABLE ||
+ old_dest == CONFLICT_LOG_DEST_ALL);
+
+ values[Anum_pg_subscription_sublogdestination - 1] =
+ CStringGetTextDatum(ConflictLogDestLabels[opts.logdest]);
+ replaces[Anum_pg_subscription_sublogdestination - 1] = true;
+
+ if (want_table && !has_oldtable)
+ {
+ char relname[NAMEDATALEN];
+ Oid nspid;
+ Oid relid;
+
+ GetConflictLogTableName(relname, subid);
+ nspid = RangeVarGetCreationNamespace(makeRangeVar(
+ NULL, relname, -1));
+
+ relid = create_conflict_log_table(nspid,
+ relname,
+ subid);
+
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(relid);
+ replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
+ true;
+ }
+ else if (!want_table && has_oldtable)
+ {
+ ObjectAddress object;
+
+ /*
+ * Conflict log tables are recorded as internal
+ * dependencies of the subscription. Drop the
+ * table if it is not required anymore to avoid
+ * stale or orphaned relations.
+ *
+ * XXX: At present, only conflict log tables are
+ * managed this way. In future if we introduce
+ * additional internal dependencies, we may need
+ * a targeted deletion to avoid deletion of any
+ * other objects.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId,
+ subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(InvalidOid);
+ replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
+ true;
+ }
+ }
+ }
+
update_tuple = true;
break;
}
@@ -2027,6 +2162,7 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
Form_pg_subscription form;
List *rstates;
bool must_use_password;
+ ObjectAddress object;
/*
* The launcher may concurrently start a new worker for this subscription.
@@ -2184,6 +2320,19 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
/* Clean up dependencies */
deleteSharedDependencyRecordsFor(SubscriptionRelationId, subid, 0);
+ /*
+ * Conflict log tables are recorded as internal dependencies of the
+ * subscription. We must drop the dependent objects before the
+ * subscription itself is removed. By using
+ * PERFORM_DELETION_SKIP_ORIGINAL, we ensure that only the conflict log
+ * table is reaped while the subscription remains for the final deletion
+ * step.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId, subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
/* Remove any associated relation synchronization states. */
RemoveSubscriptionRel(subid, InvalidOid);
@@ -3188,3 +3337,181 @@ defGetStreamingMode(DefElem *def)
def->defname)));
return LOGICALREP_STREAM_OFF; /* keep compiler quiet */
}
+
+/*
+ * Builds the TupleDesc for the conflict log table.
+ */
+static TupleDesc
+create_conflict_log_table_tupdesc(void)
+{
+ TupleDesc tupdesc;
+ int i;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_CONFLICT_ATTR_NUM);
+
+ for (i = 0; i < MAX_CONFLICT_ATTR_NUM; i++)
+ {
+ Oid type_oid = ConflictLogSchema[i].atttypid;
+
+ /*
+ * Special handling for the JSON array type for proper
+ * TupleDescInitEntry call.
+ */
+ if (type_oid == JSONARRAYOID)
+ type_oid = get_array_type(JSONOID);
+
+ TupleDescInitEntry(tupdesc, i + 1,
+ ConflictLogSchema[i].attname,
+ type_oid,
+ -1, 0);
+ }
+
+ return BlessTupleDesc(tupdesc);
+}
+
+/*
+ * Create conflict log table.
+ *
+ * The subscription owner becomes the owner of this table and has all
+ * privileges on it.
+ */
+static Oid
+create_conflict_log_table(Oid namespaceId, char *conflictrel, Oid subid)
+{
+ TupleDesc tupdesc;
+ Oid relid;
+ ObjectAddress myself;
+ ObjectAddress subaddr;
+
+ /* Report an error if the specified conflict log table already exists. */
+ if (OidIsValid(get_relname_relid(conflictrel, namespaceId)))
+ ereport(ERROR,
+ (errcode(ERRCODE_DUPLICATE_TABLE),
+ errmsg("could not generate conflict log table \"%s.%s\"",
+ get_namespace_name(namespaceId), conflictrel),
+ errdetail("A table with the internally generated name already exists."),
+ errhint("Drop the existing table or change your 'search_path' to use a different schema.")));
+
+ /*
+ * Conflict log tables must be permanent relations. Disallow creation in
+ * temporary namespaces to ensure the same.
+ */
+ if (isTempNamespace(namespaceId))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("could not generate conflict log table \"%s\"",
+ conflictrel),
+ errdetail("Conflict log tables cannot be created in a temporary namespace."),
+ errhint("Ensure your 'search_path' is set to permanent schema.")));
+
+ /* Build the tuple descriptor for the new table. */
+ tupdesc = create_conflict_log_table_tupdesc();
+
+ /* Create conflict log table. */
+ relid = heap_create_with_catalog(conflictrel,
+ namespaceId,
+ 0,
+ InvalidOid,
+ InvalidOid,
+ InvalidOid,
+ GetUserId(),
+ HEAP_TABLE_AM_OID,
+ tupdesc,
+ NIL,
+ RELKIND_RELATION,
+ RELPERSISTENCE_PERMANENT,
+ false,
+ false,
+ ONCOMMIT_NOOP,
+ (Datum) 0,
+ false,
+ false,
+ false,
+ InvalidOid,
+ NULL);
+
+ /*
+ * Establish an internal dependency between the conflict log table and
+ * the subscription. By using DEPENDENCY_INTERNAL, we ensure the table
+ * is automatically reaped when the subscription is dropped. This also
+ * prevents the table from being dropped independently unless the
+ * subscription itself is removed.
+ */
+ ObjectAddressSet(myself, RelationRelationId, relid);
+ ObjectAddressSet(subaddr, SubscriptionRelationId, subid);
+ recordDependencyOn(&myself, &subaddr, DEPENDENCY_INTERNAL);
+
+ /* Release tuple descriptor memory. */
+ FreeTupleDesc(tupdesc);
+
+ return relid;
+}
+
+/*
+ * Format the standardized internal conflict log table name for a subscription
+ *
+ * Use the OID to prevent collisions during rename operations.
+ */
+void
+GetConflictLogTableName(char *dest, Oid subid)
+{
+ snprintf(dest, NAMEDATALEN, "conflict_log_table_%u", subid);
+}
+
+/*
+ * GetLogDestination
+ *
+ * Convert string to enum by comparing against standardized labels.
+ */
+ConflictLogDest
+GetLogDestination(const char *dest)
+{
+ int i;
+
+ /* Empty string or NULL defaults to LOG. */
+ if (dest == NULL || dest[0] == '\0')
+ return CONFLICT_LOG_DEST_LOG;
+
+ for (i = CONFLICT_LOG_DEST_LOG; i <= CONFLICT_LOG_DEST_ALL; i++)
+ {
+ if (pg_strcasecmp(dest, ConflictLogDestLabels[i]) == 0)
+ return (ConflictLogDest) i;
+ }
+
+ /* Unrecognized string. */
+ return CONFLICT_LOG_DEST_INVALID;
+}
+
+/*
+ * Check if the specified relation is used as a conflict log table by any
+ * subscription.
+ */
+bool
+IsConflictLogTable(Oid relid)
+{
+ Relation rel;
+ TableScanDesc scan;
+ HeapTuple tup;
+ bool is_clt = false;
+
+ rel = table_open(SubscriptionRelationId, AccessShareLock);
+ scan = table_beginscan_catalog(rel, 0, NULL);
+
+ while (HeapTupleIsValid(tup = heap_getnext(scan, ForwardScanDirection)))
+ {
+ Form_pg_subscription subform = (Form_pg_subscription) GETSTRUCT(tup);
+
+ /* Direct Oid comparison from catalog */
+ if (OidIsValid(subform->subconflictlogrelid) &&
+ subform->subconflictlogrelid == relid)
+ {
+ is_clt = true;
+ break;
+ }
+ }
+
+ table_endscan(scan);
+ table_close(rel, AccessShareLock);
+
+ return is_clt;
+}
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index 36f24502842..cc80f0f661c 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -6806,7 +6806,7 @@ describeSubscriptions(const char *pattern, bool verbose)
printQueryOpt myopt = pset.popt;
static const bool translate_columns[] = {false, false, false, false,
false, false, false, false, false, false, false, false, false, false,
- false, false, false, false};
+ false, false, false, false, false};
if (pset.sversion < 100000)
{
@@ -6900,15 +6900,22 @@ describeSubscriptions(const char *pattern, bool verbose)
appendPQExpBuffer(&buf,
", subskiplsn AS \"%s\"\n",
gettext_noop("Skip LSN"));
+
+ /* Conflict log destination is supported in v19 and higher */
+ if (pset.sversion >= 190000)
+ {
+ appendPQExpBuffer(&buf,
+ ", sublogdestination AS \"%s\"\n",
+ gettext_noop("Conflict log destination"));
+ }
}
/* Only display subscriptions in current database. */
- appendPQExpBufferStr(&buf,
- "FROM pg_catalog.pg_subscription\n"
- "WHERE subdbid = (SELECT oid\n"
- " FROM pg_catalog.pg_database\n"
- " WHERE datname = pg_catalog.current_database())");
-
+ appendPQExpBuffer(&buf,
+ "FROM pg_catalog.pg_subscription "
+ "WHERE subdbid = (SELECT oid\n"
+ " FROM pg_catalog.pg_database\n"
+ " WHERE datname = pg_catalog.current_database())");
if (!validateSQLNamePattern(&buf, pattern, true, false,
NULL, "subname", NULL,
NULL,
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index b1ff6f6cd94..fd279d98b1b 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -2344,8 +2344,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("(", "PUBLICATION");
/* ALTER SUBSCRIPTION <name> SET ( */
else if (Matches("ALTER", "SUBSCRIPTION", MatchAny, MatchAnyN, "SET", "("))
- COMPLETE_WITH("binary", "disable_on_error", "failover",
- "max_retention_duration", "origin",
+ COMPLETE_WITH("binary", "conflict_log_destination", "disable_on_error",
+ "failover", "max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
"synchronous_commit", "two_phase");
@@ -3828,8 +3828,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("WITH (");
/* Complete "CREATE SUBSCRIPTION <name> ... WITH ( <opt>" */
else if (Matches("CREATE", "SUBSCRIPTION", MatchAnyN, "WITH", "("))
- COMPLETE_WITH("binary", "connect", "copy_data", "create_slot",
- "disable_on_error", "enabled", "failover",
+ COMPLETE_WITH("binary", "conflict_log_destination", "connect", "copy_data",
+ "create_slot", "disable_on_error", "enabled", "failover",
"max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index 55cb9b1eefa..55f4bfa0419 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -90,6 +90,7 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
* exceeded max_retention_duration, when
* defined */
+ Oid subconflictlogrelid; /* Relid of the conflict log table. */
#ifdef CATALOG_VARLEN /* variable-length fields start here */
/* Connection string to the publisher */
text subconninfo BKI_FORCE_NOT_NULL;
@@ -103,6 +104,14 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
/* List of publications subscribed to */
text subpublications[1] BKI_FORCE_NOT_NULL;
+ /*
+ * Strategy for logging replication conflicts:
+ * log - server log only,
+ * table - internal table only,
+ * all - both log and table.
+ */
+ text sublogdestination;
+
/* Only publish data originating from the specified origin */
text suborigin BKI_DEFAULT(LOGICALREP_ORIGIN_ANY);
#endif
@@ -152,12 +161,14 @@ typedef struct Subscription
* and the retention duration has not
* exceeded max_retention_duration, when
* defined */
+ Oid conflictrelid; /* conflict log table Oid */
char *conninfo; /* Connection string to the publisher */
char *slotname; /* Name of the replication slot */
char *synccommit; /* Synchronous commit setting for worker */
List *publications; /* List of publication names to subscribe to */
char *origin; /* Only publish data originating from the
* specified origin */
+ char *logdestination; /* Conflict log destination */
} Subscription;
#ifdef EXPOSE_TO_CLIENT_CODE
diff --git a/src/include/commands/subscriptioncmds.h b/src/include/commands/subscriptioncmds.h
index fb4e26a51a4..255e1e241b8 100644
--- a/src/include/commands/subscriptioncmds.h
+++ b/src/include/commands/subscriptioncmds.h
@@ -17,6 +17,7 @@
#include "catalog/objectaddress.h"
#include "parser/parse_node.h"
+#include "replication/conflict.h"
extern ObjectAddress CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bool isTopLevel);
@@ -36,4 +37,8 @@ extern void CheckSubDeadTupleRetention(bool check_guc, bool sub_disabled,
bool retention_active,
bool max_retention_set);
+extern void GetConflictLogTableName(char *dest, Oid subid);
+extern ConflictLogDest GetLogDestination(const char *dest);
+extern bool IsConflictLogTable(Oid relid);
+
#endif /* SUBSCRIPTIONCMDS_H */
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index c8fbf9e51b8..70f8744b381 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -10,6 +10,7 @@
#define CONFLICT_H
#include "access/xlogdefs.h"
+#include "catalog/pg_type.h"
#include "nodes/pg_list.h"
#include "utils/timestamp.h"
@@ -79,6 +80,55 @@ typedef struct ConflictTupleInfo
* conflicting local row occurred */
} ConflictTupleInfo;
+/*
+ * Conflict log destination types.
+ *
+ * Internally we use these enum values for fast comparison, but we store
+ * the string equivalent in pg_subscription.sublogdestination.
+ */
+typedef enum ConflictLogDest
+{
+ CONFLICT_LOG_DEST_INVALID = 0,
+ CONFLICT_LOG_DEST_LOG, /* "log" (default) */
+ CONFLICT_LOG_DEST_TABLE, /* "table" */
+ CONFLICT_LOG_DEST_ALL /* "all" */
+} ConflictLogDest;
+
+/*
+ * Array mapping for converting internal enum to string.
+ */
+static const char *const ConflictLogDestLabels[] = {
+ [CONFLICT_LOG_DEST_LOG] = "log",
+ [CONFLICT_LOG_DEST_TABLE] = "table",
+ [CONFLICT_LOG_DEST_ALL] = "all"
+};
+
+/* Structure to hold metadata for one column of the conflict log table */
+typedef struct ConflictLogColumnDef
+{
+ const char *attname; /* Column name */
+ Oid atttypid; /* Data type OID */
+} ConflictLogColumnDef;
+
+/* The single source of truth for the conflict log table schema */
+static const ConflictLogColumnDef ConflictLogSchema[] =
+{
+ { .attname = "relid", .atttypid = OIDOID },
+ { .attname = "schemaname", .atttypid = TEXTOID },
+ { .attname = "relname", .atttypid = TEXTOID },
+ { .attname = "conflict_type", .atttypid = TEXTOID },
+ { .attname = "remote_xid", .atttypid = XIDOID },
+ { .attname = "remote_commit_lsn",.atttypid = LSNOID },
+ { .attname = "remote_commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "remote_origin", .atttypid = TEXTOID },
+ { .attname = "replica_identity", .atttypid = JSONOID },
+ { .attname = "remote_tuple", .atttypid = JSONOID },
+ { .attname = "local_conflicts", .atttypid = JSONARRAYOID }
+};
+
+/* Define the count using the array size */
+#define MAX_CONFLICT_ATTR_NUM (sizeof(ConflictLogSchema) / sizeof(ConflictLogSchema[0]))
+
extern bool GetTupleTransactionInfo(TupleTableSlot *localslot,
TransactionId *xmin,
RepOriginId *localorigin,
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index 327d1e7731f..a678471f4c2 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -116,18 +116,18 @@ CREATE SUBSCRIPTION regress_testsub4 CONNECTION 'dbname=regress_doesnotexist' PU
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub4 SET (origin = any);
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub3;
@@ -145,10 +145,10 @@ ALTER SUBSCRIPTION regress_testsub CONNECTION 'foobar';
ERROR: invalid connection string syntax: missing "=" after "foobar" in connection info string
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET PUBLICATION testpub2, testpub3 WITH (refresh = false);
@@ -157,10 +157,10 @@ ALTER SUBSCRIPTION regress_testsub SET (slot_name = 'newname');
ALTER SUBSCRIPTION regress_testsub SET (password_required = false);
ALTER SUBSCRIPTION regress_testsub SET (run_as_owner = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (password_required = true);
@@ -176,10 +176,10 @@ ERROR: unrecognized subscription parameter: "create_slot"
-- ok
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/12345');
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345 | log
(1 row)
-- ok - with lsn = NONE
@@ -188,10 +188,10 @@ ALTER SUBSCRIPTION regress_testsub SKIP (lsn = NONE);
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/0');
ERROR: invalid WAL location (LSN): 0/0
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
BEGIN;
@@ -223,10 +223,10 @@ ALTER SUBSCRIPTION regress_testsub_foo SET (synchronous_commit = foobar);
ERROR: invalid value for parameter "synchronous_commit": "foobar"
HINT: Available values: local, remote_write, remote_apply, on, off.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
----------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+---------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
-- rename back to keep the rest simple
@@ -255,19 +255,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (binary = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -279,27 +279,27 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = parallel);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- fail - publication already exists
@@ -314,10 +314,10 @@ ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refr
ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refresh = false);
ERROR: publication "testpub1" is already in subscription "regress_testsub"
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- fail - publication used more than once
@@ -332,10 +332,10 @@ ERROR: publication "testpub3" is not in subscription "regress_testsub"
-- ok - delete publications
ALTER SUBSCRIPTION regress_testsub DROP PUBLICATION testpub1, testpub2 WITH (refresh = false);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -371,19 +371,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- we can alter streaming when two_phase enabled
ALTER SUBSCRIPTION regress_testsub SET (streaming = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -393,10 +393,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -409,18 +409,18 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (disable_on_error = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -433,10 +433,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -450,19 +450,19 @@ NOTICE: max_retention_duration is ineffective when retain_dead_tuples is disabl
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- ok
ALTER SUBSCRIPTION regress_testsub SET (max_retention_duration = 0);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -517,7 +517,158 @@ COMMIT;
-- ok, owning it is enough for this stuff
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG DESTINATION TESTS
+--
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+-- fail - unrecognized format value
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
+ERROR: unrecognized conflict_log_destination value: "invalid"
+HINT: Valid values are "log", "table", and "all".
+-- verify sublogdestination is 'log' and relid is 0 (InvalidOid) for default case
+CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+SELECT subname, sublogdestination, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
+ subname | sublogdestination | subconflictlogrelid
+------------------------------+-------------------+---------------------
+ regress_conflict_log_default | log | 0
+(1 row)
+
+-- verify empty string defaults to 'log'
+CREATE SUBSCRIPTION regress_conflict_empty_str CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = '');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+SELECT subname, sublogdestination, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
+ subname | sublogdestination | subconflictlogrelid
+----------------------------+-------------------+---------------------
+ regress_conflict_empty_str | log | 0
+(1 row)
+
+-- this should generate an internal table named conflict_log_table_$subid$
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- check metadata in pg_subscription: destination should be 'table' and relid valid
+SELECT subname, sublogdestination, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname | sublogdestination | has_relid
+------------------------+-------------------+-----------
+ regress_conflict_test1 | table | t
+(1 row)
+
+-- verify the physical table exists and its OID matches subconflictlogrelid
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND c.oid = s.subconflictlogrelid;
+ count
+-------
+ 1
+(1 row)
+
+-- check if the internal table has the correct schema (11 columns)
+SELECT count(*)
+FROM pg_attribute a
+JOIN pg_class c ON a.attrelid = c.oid
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0;
+ count
+-------
+ 11
+(1 row)
+
+-- ALTER: State transitions
+-- transition from 'log' to 'all'
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
+-- verify metadata after ALTER (destination should be 'all')
+SELECT subname, sublogdestination, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | sublogdestination | has_relid
+------------------------+-------------------+-----------
+ regress_conflict_test2 | all | t
+(1 row)
+
+-- transition from 'all' to 'table' (should NOT drop the table, only change destination string)
+SELECT subconflictlogrelid AS old_relid FROM pg_subscription WHERE subname = 'regress_conflict_test2' \gset
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'table');
+SELECT sublogdestination, subconflictlogrelid = :old_relid AS relid_unchanged
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ sublogdestination | relid_unchanged
+-------------------+-----------------
+ table | t
+(1 row)
+
+-- transition from 'table' to 'log' (should drop the table and clear relid)
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log');
+SELECT sublogdestination, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ sublogdestination | subconflictlogrelid
+-------------------+---------------------
+ log | 0
+(1 row)
+
+-- verify the physical table is gone
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test2';
+ count
+-------
+ 0
+(1 row)
+
+-- ensure drop table not allowed and DROP SUBSCRIPTION reaps the table
+-- re-enable table logging for verification
+ALTER SUBSCRIPTION regress_conflict_test1 SET (conflict_log_destination = 'table');
+-- fail - drop table not allowed due to internal dependency
+-- use DO block to hide OID in error message
+DO $$
+BEGIN
+ EXECUTE 'DROP TABLE ' || (SELECT 'conflict_log_table_' || oid FROM pg_subscription WHERE subname = 'regress_conflict_test1');
+EXCEPTION WHEN dependent_objects_still_exist THEN
+ RAISE NOTICE 'captured expected error: dependent_objects_still_exist';
+END $$;
+NOTICE: captured expected error: dependent_objects_still_exist
+-- PUBLICATION: Verify internal tables are not publishable
+-- pg_relation_is_publishable should return false for internal conflict log tables
+SELECT pg_relation_is_publishable(subconflictlogrelid)
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ pg_relation_is_publishable
+----------------------------
+ f
+(1 row)
+
+-- CLEANUP: Proper drop reaps the table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+-- Verify the table OID for reap check
+SELECT 'conflict_log_table_' || oid AS internal_tablename FROM pg_subscription WHERE subname = 'regress_conflict_test1' \gset
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the internal table was reaped via dependency
+SELECT to_regclass(:'internal_tablename');
+ to_regclass
+-------------
+
+(1 row)
+
+ALTER SUBSCRIPTION regress_conflict_log_default DISABLE;
+ALTER SUBSCRIPTION regress_conflict_log_default SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_log_default;
+ALTER SUBSCRIPTION regress_conflict_empty_str DISABLE;
+ALTER SUBSCRIPTION regress_conflict_empty_str SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_empty_str;
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
RESET SESSION AUTHORIZATION;
+DROP SCHEMA clt;
+ERROR: schema "clt" does not exist
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
DROP ROLE regress_subscription_user3;
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index ef0c298d2df..df0e4649007 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -365,7 +365,115 @@ COMMIT;
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG DESTINATION TESTS
+--
+
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+
+-- fail - unrecognized format value
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
+
+-- verify sublogdestination is 'log' and relid is 0 (InvalidOid) for default case
+CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+SELECT subname, sublogdestination, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
+
+-- verify empty string defaults to 'log'
+CREATE SUBSCRIPTION regress_conflict_empty_str CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = '');
+SELECT subname, sublogdestination, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
+
+-- this should generate an internal table named conflict_log_table_$subid$
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+
+-- check metadata in pg_subscription: destination should be 'table' and relid valid
+SELECT subname, sublogdestination, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- verify the physical table exists and its OID matches subconflictlogrelid
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND c.oid = s.subconflictlogrelid;
+
+-- check if the internal table has the correct schema (11 columns)
+SELECT count(*)
+FROM pg_attribute a
+JOIN pg_class c ON a.attrelid = c.oid
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0;
+
+-- ALTER: State transitions
+-- transition from 'log' to 'all'
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
+
+-- verify metadata after ALTER (destination should be 'all')
+SELECT subname, sublogdestination, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'all' to 'table' (should NOT drop the table, only change destination string)
+SELECT subconflictlogrelid AS old_relid FROM pg_subscription WHERE subname = 'regress_conflict_test2' \gset
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'table');
+SELECT sublogdestination, subconflictlogrelid = :old_relid AS relid_unchanged
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'table' to 'log' (should drop the table and clear relid)
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log');
+SELECT sublogdestination, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- verify the physical table is gone
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test2';
+
+-- ensure drop table not allowed and DROP SUBSCRIPTION reaps the table
+-- re-enable table logging for verification
+ALTER SUBSCRIPTION regress_conflict_test1 SET (conflict_log_destination = 'table');
+
+-- fail - drop table not allowed due to internal dependency
+-- use DO block to hide OID in error message
+DO $$
+BEGIN
+ EXECUTE 'DROP TABLE ' || (SELECT 'conflict_log_table_' || oid FROM pg_subscription WHERE subname = 'regress_conflict_test1');
+EXCEPTION WHEN dependent_objects_still_exist THEN
+ RAISE NOTICE 'captured expected error: dependent_objects_still_exist';
+END $$;
+
+-- PUBLICATION: Verify internal tables are not publishable
+-- pg_relation_is_publishable should return false for internal conflict log tables
+SELECT pg_relation_is_publishable(subconflictlogrelid)
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- CLEANUP: Proper drop reaps the table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+
+-- Verify the table OID for reap check
+SELECT 'conflict_log_table_' || oid AS internal_tablename FROM pg_subscription WHERE subname = 'regress_conflict_test1' \gset
+
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the internal table was reaped via dependency
+SELECT to_regclass(:'internal_tablename');
+
+ALTER SUBSCRIPTION regress_conflict_log_default DISABLE;
+ALTER SUBSCRIPTION regress_conflict_log_default SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_log_default;
+
+ALTER SUBSCRIPTION regress_conflict_empty_str DISABLE;
+ALTER SUBSCRIPTION regress_conflict_empty_str SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_empty_str;
+
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+
RESET SESSION AUTHORIZATION;
+DROP SCHEMA clt;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
DROP ROLE regress_subscription_user3;
--
2.43.0
[text/x-patch] v14-0002-Implement-the-conflict-insertion-infrastructure-.patch (27.0K, ../../CALDaNm17TRArpX3Axy=2AQBSFbuf-E5Hj0+rNYcMSfXLxenmJQ@mail.gmail.com/3-v14-0002-Implement-the-conflict-insertion-infrastructure-.patch)
download | inline diff:
From 4aa8a0520b706b247f850dcc9a05fdefb01826e5 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Sat, 20 Dec 2025 15:20:09 +0530
Subject: [PATCH v14 2/3] Implement the conflict insertion infrastructure into
the conflict log table
Note: A single remote tuple may conflict with multiple local tuples when conflict type
is CT_MULTIPLE_UNIQUE_CONFLICTS, so for handling this case we create a single row in
conflict log table with respect to each remote conflict tuple even if it conflicts with
multiple local tuples and we store the multiple conflict tuples as a single JSON array
element in format as
[ { "xid": "1001", "commit_ts": "...", "origin": "...", "tuple": {...} }, ... ]
We can extract the elements of the local tuples from the conflict log table row
as given in below example.
SELECT remote_xid, relname, remote_origin, local_conflicts[1] ->> 'xid' AS local_xid,
local_conflicts[1] ->> 'tuple' AS local_tuple
FROM myschema.conflict_log_history2;
remote_xid | relname | remote_origin | local_xid | local_tuple
------------+----------+---------------+-----------+---------------------
760 | test | pg_16406 | 771 | {"a":1,"b":10}
765 | conf_tab | pg_16406 | 775 | {"a":2,"b":2,"c":2}
---
src/backend/replication/logical/conflict.c | 619 +++++++++++++++++++--
src/backend/replication/logical/launcher.c | 1 +
src/backend/replication/logical/worker.c | 38 +-
src/include/replication/conflict.h | 3 +
src/include/replication/worker_internal.h | 7 +
5 files changed, 635 insertions(+), 33 deletions(-)
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 16695592265..5f753cd8042 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -15,13 +15,22 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/tableam.h"
+#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "pgstat.h"
#include "replication/conflict.h"
#include "replication/worker_internal.h"
#include "storage/lmgr.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/lsyscache.h"
+#include "utils/pg_lsn.h"
+#include "utils/jsonb.h"
+
+#define MAX_LOCAL_CONFLICT_INFO_ATTRS 5
static const char *const ConflictTypeNames[] = {
[CT_INSERT_EXISTS] = "insert_exists",
@@ -50,8 +59,27 @@ static char *build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *localslot,
TupleTableSlot *remoteslot,
Oid indexoid);
+static void build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull);
static char *build_index_value_desc(EState *estate, Relation localrel,
TupleTableSlot *slot, Oid indexoid);
+static Datum tuple_table_slot_to_json_datum(TupleTableSlot *slot);
+static Datum tuple_table_slot_to_indextup_json(EState *estate,
+ Relation localrel,
+ Oid replica_index,
+ TupleTableSlot *slot);
+static TupleDesc build_conflict_tupledesc(void);
+static Datum build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples);
+static void prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot);
/*
* Get the xmin and commit timestamp data (origin and timestamp) associated
@@ -105,11 +133,19 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
ConflictType type, TupleTableSlot *searchslot,
TupleTableSlot *remoteslot, List *conflicttuples)
{
- Relation localrel = relinfo->ri_RelationDesc;
- StringInfoData err_detail;
+ Relation localrel = relinfo->ri_RelationDesc;
+ StringInfoData err_detail;
+ ConflictLogDest dest;
+ Relation conflictlogrel;
initStringInfo(&err_detail);
+ /*
+ * Get both the conflict log destination and the opened conflict log
+ * relation for insertion.
+ */
+ conflictlogrel = GetConflictLogTableInfo(&dest);
+
/* Form errdetail message by combining conflicting tuples information. */
foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
errdetail_apply_conflict(estate, relinfo, type, searchslot,
@@ -120,15 +156,69 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
conflicttuple->ts,
&err_detail);
+ /* Insert to table if destination is 'table' or 'all' */
+ if (conflictlogrel)
+ {
+ Assert(dest == CONFLICT_LOG_DEST_TABLE ||
+ dest == CONFLICT_LOG_DEST_ALL);
+
+ if (ValidateConflictLogTable(conflictlogrel))
+ {
+ /*
+ * Prepare the conflict log tuple. If the error level is below
+ * ERROR, insert it immediately. Otherwise, defer the insertion to
+ * a new transaction after the current one aborts, ensuring the
+ * insertion of the log tuple is not rolled back.
+ */
+ prepare_conflict_log_tuple(estate,
+ relinfo->ri_RelationDesc,
+ conflictlogrel,
+ type,
+ searchslot,
+ conflicttuples,
+ remoteslot);
+ if (elevel < ERROR)
+ InsertConflictLogTuple(conflictlogrel);
+ }
+ else
+ ereport(WARNING,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("conflict log table \"%s.%s\" structure changed, skipping insertion",
+ get_namespace_name(RelationGetNamespace(conflictlogrel)),
+ RelationGetRelationName(conflictlogrel)));
+
+ table_close(conflictlogrel, RowExclusiveLock);
+ }
+
pgstat_report_subscription_conflict(MySubscription->oid, type);
- ereport(elevel,
- errcode_apply_conflict(type),
- errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
- get_namespace_name(RelationGetNamespace(localrel)),
- RelationGetRelationName(localrel),
- ConflictTypeNames[type]),
- errdetail_internal("%s", err_detail.data));
+ /* Decide what detail to show in server logs. */
+ if (dest == CONFLICT_LOG_DEST_LOG || dest == CONFLICT_LOG_DEST_ALL)
+ {
+ /* Standard reporting with full internal details. */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail_internal("%s", err_detail.data));
+ }
+ else
+ {
+ /*
+ * 'table' only: Report the error msg but omit raw tuple data from
+ * server logs since it's already captured in the internal table.
+ */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail("Conflict details logged to internal table with OID %u.",
+ MySubscription->conflictrelid));
+ }
}
/*
@@ -162,6 +252,142 @@ InitConflictIndexes(ResultRelInfo *relInfo)
relInfo->ri_onConflictArbiterIndexes = uniqueIndexes;
}
+/*
+ * GetConflictLogTableInfo
+ *
+ * Fetches conflict logging metadata from the cached MySubscription pointer.
+ * Sets the destination enum in *log_dest and, if applicable, opens and
+ * returns the relation handle for the internal log table.
+ */
+Relation
+GetConflictLogTableInfo(ConflictLogDest *log_dest)
+{
+ Oid conflictlogrelid;
+ Relation conflictlogrel = NULL;
+
+ /*
+ * Convert the text log destination to the internal enum. MySubscription
+ * already contains the data from pg_subscription.
+ */
+ *log_dest = GetLogDestination(MySubscription->logdestination);
+ conflictlogrelid = MySubscription->conflictrelid;
+
+ /* If destination is 'log' only, no table to open. */
+ if (*log_dest == CONFLICT_LOG_DEST_LOG)
+ return NULL;
+
+ Assert(OidIsValid(conflictlogrelid));
+
+ conflictlogrel = table_open(conflictlogrelid, RowExclusiveLock);
+
+ /* Conflict log table is dropped or not accessible. */
+ if (conflictlogrel == NULL)
+ ereport(WARNING,
+ (errcode(ERRCODE_UNDEFINED_TABLE),
+ errmsg("conflict log table with OID %u does not exist",
+ conflictlogrelid)));
+
+ return conflictlogrel;
+}
+
+/*
+ * InsertConflictLogTuple
+ *
+ * Insert conflict log tuple into the conflict log table. It uses
+ * HEAP_INSERT_NO_LOGICAL to explicitly block logical decoding of the tuple
+ * inserted into the conflict log table.
+ */
+void
+InsertConflictLogTuple(Relation conflictlogrel)
+{
+ int options = HEAP_INSERT_NO_LOGICAL;
+
+ /* A valid tuple must be prepared and stored in MyLogicalRepWorker. */
+ Assert(MyLogicalRepWorker->conflict_log_tuple != NULL);
+
+ heap_insert(conflictlogrel, MyLogicalRepWorker->conflict_log_tuple,
+ GetCurrentCommandId(true), options, NULL);
+
+ /* Free conflict log tuple. */
+ heap_freetuple(MyLogicalRepWorker->conflict_log_tuple);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+}
+
+/*
+ * ValidateConflictLogTable - Validate conflict log table
+ *
+ * Validate whether the conflict log table is still suitable for considering as
+ * conflict log table.
+ */
+bool
+ValidateConflictLogTable(Relation rel)
+{
+ Relation pg_attribute;
+ HeapTuple atup;
+ ScanKeyData scankey;
+ SysScanDesc scan;
+ Form_pg_attribute attForm;
+ int attcnt = 0;
+ bool tbl_ok = true;
+
+ /*
+ * Check whether the table definition including its column names, data
+ * types, and column ordering meets the requirements for conflict log
+ * table.
+ */
+ pg_attribute = table_open(AttributeRelationId, AccessShareLock);
+ ScanKeyInit(&scankey,
+ Anum_pg_attribute_attrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationGetRelid(rel)));
+
+ scan = systable_beginscan(pg_attribute, AttributeRelidNumIndexId, true,
+ SnapshotSelf, 1, &scankey);
+
+ /* We only need to check up to MAX_CONFLICT_ATTR_NUM attributes */
+ while (HeapTupleIsValid(atup = systable_getnext(scan)))
+ {
+ const ConflictLogColumnDef *expected;
+ int schema_idx;
+
+ attForm = (Form_pg_attribute) GETSTRUCT(atup);
+
+ /* Skip system columns and dropped columns */
+ if (attForm->attnum < 1 || attForm->attisdropped)
+ continue;
+
+ attcnt++;
+
+ /* attnum 1 corresponds to index 0 in ConflictLogSchema */
+ schema_idx = attForm->attnum - 1;
+
+ /* Check against the central schema definition */
+ if (schema_idx >= MAX_CONFLICT_ATTR_NUM)
+ {
+ /* Found an extra column beyond the required set */
+ tbl_ok = false;
+ break;
+ }
+
+ expected = &ConflictLogSchema[schema_idx];
+
+ if (attForm->atttypid != expected->atttypid ||
+ strcmp(NameStr(attForm->attname), expected->attname) != 0)
+ {
+ tbl_ok = false;
+ break;
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_attribute, AccessShareLock);
+
+ if (attcnt != MAX_CONFLICT_ATTR_NUM || !tbl_ok)
+ return false;
+
+ return true;
+}
+
/*
* Add SQLSTATE error code to the current conflict report.
*/
@@ -472,6 +698,40 @@ build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
return tuple_value.data;
}
+/*
+ * Helper function to extract the "raw" index key Datums and their null flags
+ * from a TupleTableSlot, given an already open index descriptor.
+ * This is the reusable core logic.
+ */
+static void
+build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull)
+{
+ TupleTableSlot *tableslot = slot;
+
+ /*
+ * If the slot is a virtual slot, copy it into a heap tuple slot as
+ * FormIndexDatum only works with heap tuple slots.
+ */
+ if (TTS_IS_VIRTUAL(slot))
+ {
+ /* Slot is created within the EState's tuple table */
+ tableslot = table_slot_create(localrel, &estate->es_tupleTable);
+ tableslot = ExecCopySlot(tableslot, slot);
+ }
+
+ /*
+ * Initialize ecxt_scantuple for potential use in FormIndexDatum
+ */
+ GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+
+ /* Form the index datums */
+ FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values,
+ isnull);
+}
+
/*
* Helper functions to construct a string describing the contents of an index
* entry. See BuildIndexValueDescription for details.
@@ -487,41 +747,336 @@ build_index_value_desc(EState *estate, Relation localrel, TupleTableSlot *slot,
Relation indexDesc;
Datum values[INDEX_MAX_KEYS];
bool isnull[INDEX_MAX_KEYS];
- TupleTableSlot *tableslot = slot;
- if (!tableslot)
+ if (!slot)
return NULL;
Assert(CheckRelationOidLockedByMe(indexoid, RowExclusiveLock, true));
indexDesc = index_open(indexoid, NoLock);
- /*
- * If the slot is a virtual slot, copy it into a heap tuple slot as
- * FormIndexDatum only works with heap tuple slots.
- */
- if (TTS_IS_VIRTUAL(slot))
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+
+ index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+
+ index_close(indexDesc, NoLock);
+
+ return index_value;
+}
+
+/*
+ * tuple_table_slot_to_json_datum
+ *
+ * Helper function to convert a TupleTableSlot to Jsonb.
+ */
+static Datum
+tuple_table_slot_to_json_datum(TupleTableSlot *slot)
+{
+ HeapTuple tuple;
+ Datum datum;
+ Datum json;
+
+ Assert(slot != NULL);
+
+ tuple = ExecCopySlotHeapTuple(slot);
+ datum = heap_copy_tuple_as_datum(tuple, slot->tts_tupleDescriptor);
+
+ json = DirectFunctionCall1(row_to_json, datum);
+ heap_freetuple(tuple);
+
+ return json;
+}
+
+/*
+ * tuple_table_slot_to_indextup_json
+ *
+ * Fetch replica identity key from the tuple table slot and convert into a
+ * jsonb datum.
+ */
+static Datum
+tuple_table_slot_to_indextup_json(EState *estate, Relation localrel,
+ Oid indexid, TupleTableSlot *slot)
+{
+ Relation indexDesc;
+ Datum values[INDEX_MAX_KEYS];
+ bool isnull[INDEX_MAX_KEYS];
+ HeapTuple tuple;
+ TupleDesc tupdesc;
+ Datum datum;
+
+ Assert(slot != NULL);
+
+ Assert(CheckRelationOidLockedByMe(indexid, RowExclusiveLock, true));
+
+ indexDesc = index_open(indexid, NoLock);
+
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+ tupdesc = RelationGetDescr(indexDesc);
+
+ /* Bless the tupdesc so it can be looked up by row_to_json. */
+ BlessTupleDesc(tupdesc);
+
+ /* Form the replica identity tuple. */
+ tuple = heap_form_tuple(tupdesc, values, isnull);
+ datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ index_close(indexDesc, NoLock);
+
+ /* Convert to a JSONB datum. */
+ return DirectFunctionCall1(row_to_json, datum);
+}
+
+/*
+ * Initialize the tuple descriptor for local conflict info.
+ */
+static TupleDesc
+build_conflict_tupledesc(void)
+{
+ TupleDesc tupdesc;
+ int attno = 1;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ TupleDescInitEntry(tupdesc, (AttrNumber) attno++, "xid",
+ XIDOID, -1, 0);
+ TupleDescInitEntry(tupdesc, (AttrNumber) attno++, "commit_ts",
+ TIMESTAMPTZOID, -1, 0);
+ TupleDescInitEntry(tupdesc, (AttrNumber) attno++, "origin",
+ TEXTOID, -1, 0);
+ TupleDescInitEntry(tupdesc, (AttrNumber) attno++, "key",
+ JSONOID, -1, 0);
+ TupleDescInitEntry(tupdesc, (AttrNumber) attno, "tuple",
+ JSONOID, -1, 0);
+
+ BlessTupleDesc(tupdesc);
+
+ Assert(attno == MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ return tupdesc;
+}
+
+/*
+ * Builds the local conflicts JSONB array column from the list of
+ * ConflictTupleInfo objects.
+ *
+ * Example output structure:
+ * [ { "xid": "1001", "commit_ts": "...", "origin": "...", "tuple": {...} }, ... ]
+ */
+static Datum
+build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples)
+{
+ ListCell *lc;
+ List *json_datums = NIL; /* List to hold the row_to_json results (type json) */
+ Datum *json_datum_array;
+ bool *json_null_array;
+ Datum json_array_datum;
+ int num_conflicts;
+ int i;
+ int16 typlen;
+ bool typbyval;
+ char typalign;
+ TupleDesc tupdesc;
+
+ /* Build local conflicts tuple descriptor. */
+ tupdesc = build_conflict_tupledesc();
+
+ /* Process local conflict tuple list and prepare an array of JSON. */
+ foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
{
- tableslot = table_slot_create(localrel, &estate->es_tupleTable);
- tableslot = ExecCopySlot(tableslot, slot);
+ Datum values[MAX_LOCAL_CONFLICT_INFO_ATTRS];
+ bool nulls[MAX_LOCAL_CONFLICT_INFO_ATTRS];
+ char *origin_name = NULL;
+ HeapTuple tuple;
+ Datum json_datum;
+ int attno;
+
+ memset(values, 0, sizeof(Datum) * MAX_LOCAL_CONFLICT_INFO_ATTRS);
+ memset(nulls, 0, sizeof(bool) * MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ attno = 0;
+ values[attno++] = TransactionIdGetDatum(conflicttuple->xmin);
+
+ if (conflicttuple->ts)
+ values[attno++] = TimestampTzGetDatum(conflicttuple->ts);
+ else
+ nulls[attno++] = true;
+
+ if (conflicttuple->origin != InvalidRepOriginId)
+ replorigin_by_oid(conflicttuple->origin, true, &origin_name);
+
+ /* Store empty string if origin name for the tuple is NULL. */
+ if (origin_name != NULL)
+ values[attno++] = CStringGetTextDatum(origin_name);
+ else
+ nulls[attno++] = true;
+
+ /*
+ * Add the conflicting key values in the case of a unique constraint
+ * violation.
+ */
+ if (conflict_type == CT_INSERT_EXISTS ||
+ conflict_type == CT_UPDATE_EXISTS ||
+ conflict_type == CT_MULTIPLE_UNIQUE_CONFLICTS)
+ {
+ Oid indexoid = conflicttuple->indexoid;
+
+ Assert(OidIsValid(indexoid) && conflicttuple->slot &&
+ CheckRelationOidLockedByMe(indexoid, RowExclusiveLock,
+ true));
+ values[attno++] =
+ tuple_table_slot_to_indextup_json(estate, rel,
+ indexoid,
+ conflicttuple->slot);
+ }
+ else
+ nulls[attno++] = true;
+
+ /* Convert conflicting tuple to JSON datum. */
+ if (conflicttuple->slot)
+ values[attno] = tuple_table_slot_to_json_datum(conflicttuple->slot);
+ else
+ nulls[attno] = true;
+
+ Assert(attno + 1 == MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ tuple = heap_form_tuple(tupdesc, values, nulls);
+
+ json_datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ /*
+ * Build the higher level JSON datum in format described in function
+ * header.
+ */
+ json_datum = DirectFunctionCall1(row_to_json, json_datum);
+
+ /* Done with the temporary tuple. */
+ heap_freetuple(tuple);
+
+ /* Add to the array element. */
+ json_datums = lappend(json_datums, (void *) json_datum);
}
- /*
- * Initialize ecxt_scantuple for potential use in FormIndexDatum when
- * index expressions are present.
- */
- GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+ num_conflicts = list_length(json_datums);
- /*
- * The values/nulls arrays passed to BuildIndexValueDescription should be
- * the results of FormIndexDatum, which are the "raw" input to the index
- * AM.
- */
- FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values, isnull);
+ json_datum_array = (Datum *) palloc(num_conflicts * sizeof(Datum));
+ json_null_array = (bool *) palloc0(num_conflicts * sizeof(bool));
- index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+ i = 0;
+ foreach(lc, json_datums)
+ {
+ json_datum_array[i] = (Datum) lfirst(lc);
+ i++;
+ }
- index_close(indexDesc, NoLock);
+ /* Construct the json[] array Datum. */
+ get_typlenbyvalalign(JSONOID, &typlen, &typbyval, &typalign);
+ json_array_datum = PointerGetDatum(construct_array(json_datum_array,
+ num_conflicts,
+ JSONOID,
+ typlen,
+ typbyval,
+ typalign));
+ pfree(json_datum_array);
+ pfree(json_null_array);
+
+ return json_array_datum;
+}
- return index_value;
+/*
+ * prepare_conflict_log_tuple
+ *
+ * This routine prepares a tuple detailing a conflict encountered during
+ * logical replication. The prepared tuple will be stored in
+ * MyLogicalRepWorker->conflict_log_tuple which should be inserted into the
+ * conflict log table by calling InsertConflictLogTuple.
+ */
+static void
+prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot)
+{
+ Datum values[MAX_CONFLICT_ATTR_NUM];
+ bool nulls[MAX_CONFLICT_ATTR_NUM];
+ int attno;
+ char *remote_origin = NULL;
+ MemoryContext oldctx;
+
+ Assert(MyLogicalRepWorker->conflict_log_tuple == NULL);
+
+ /* Initialize values and nulls arrays. */
+ memset(values, 0, sizeof(Datum) * MAX_CONFLICT_ATTR_NUM);
+ memset(nulls, 0, sizeof(bool) * MAX_CONFLICT_ATTR_NUM);
+
+ /* Populate the values and nulls arrays. */
+ attno = 0;
+ values[attno++] = ObjectIdGetDatum(RelationGetRelid(rel));
+
+ values[attno++] =
+ CStringGetTextDatum(get_namespace_name(RelationGetNamespace(rel)));
+
+ values[attno++] = CStringGetTextDatum(RelationGetRelationName(rel));
+
+ values[attno++] = CStringGetTextDatum(ConflictTypeNames[conflict_type]);
+
+ if (TransactionIdIsValid(remote_xid))
+ values[attno++] = TransactionIdGetDatum(remote_xid);
+ else
+ nulls[attno++] = true;
+
+ values[attno++] = LSNGetDatum(remote_final_lsn);
+
+ if (remote_commit_ts > 0)
+ values[attno++] = TimestampTzGetDatum(remote_commit_ts);
+ else
+ nulls[attno++] = true;
+
+ if (replorigin_session_origin != InvalidRepOriginId)
+ replorigin_by_oid(replorigin_session_origin, true, &remote_origin);
+
+ if (remote_origin != NULL)
+ values[attno++] = CStringGetTextDatum(remote_origin);
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(searchslot))
+ {
+ Oid replica_index = GetRelationIdentityOrPK(rel);
+
+ /*
+ * If the table has a valid replica identity index, build the index
+ * json datum from key value. Otherwise, construct it from the complete
+ * tuple in REPLICA IDENTITY FULL cases.
+ */
+ if (OidIsValid(replica_index))
+ values[attno++] = tuple_table_slot_to_indextup_json(estate, rel,
+ replica_index,
+ searchslot);
+ else
+ values[attno++] = tuple_table_slot_to_json_datum(searchslot);
+ }
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(remoteslot))
+ values[attno++] = tuple_table_slot_to_json_datum(remoteslot);
+ else
+ nulls[attno++] = true;
+
+ values[attno] = build_local_conflicts_json_array(estate, rel,
+ conflict_type,
+ conflicttuples);
+
+ Assert(attno + 1 == MAX_CONFLICT_ATTR_NUM);
+
+ oldctx = MemoryContextSwitchTo(ApplyContext);
+ MyLogicalRepWorker->conflict_log_tuple =
+ heap_form_tuple(RelationGetDescr(conflictlogrel), values, nulls);
+ MemoryContextSwitchTo(oldctx);
}
diff --git a/src/backend/replication/logical/launcher.c b/src/backend/replication/logical/launcher.c
index 3991e1495d4..bc7e1d9ebde 100644
--- a/src/backend/replication/logical/launcher.c
+++ b/src/backend/replication/logical/launcher.c
@@ -477,6 +477,7 @@ retry:
worker->oldest_nonremovable_xid = retain_dead_tuples
? MyReplicationSlot->data.xmin
: InvalidTransactionId;
+ worker->conflict_log_tuple = NULL;
worker->last_lsn = InvalidXLogRecPtr;
TIMESTAMP_NOBEGIN(worker->last_send_time);
TIMESTAMP_NOBEGIN(worker->last_recv_time);
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index fc64476a9ef..ccd1b2c6e81 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -482,7 +482,9 @@ static bool MySubscriptionValid = false;
static List *on_commit_wakeup_workers_subids = NIL;
bool in_remote_transaction = false;
-static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+TransactionId remote_xid = InvalidTransactionId;
+TimestampTz remote_commit_ts = 0;
/* fields valid only when processing streamed transaction */
static bool in_streamed_transaction = false;
@@ -1219,6 +1221,8 @@ apply_handle_begin(StringInfo s)
set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
remote_final_lsn = begin_data.final_lsn;
+ remote_commit_ts = begin_data.committime;
+ remote_xid = begin_data.xid;
maybe_start_skipping_changes(begin_data.final_lsn);
@@ -1745,6 +1749,10 @@ apply_handle_stream_start(StringInfo s)
/* extract XID of the top-level transaction */
stream_xid = logicalrep_read_stream_start(s, &first_segment);
+ remote_xid = stream_xid;
+ remote_final_lsn = InvalidXLogRecPtr;
+ remote_commit_ts = 0;
+
if (!TransactionIdIsValid(stream_xid))
ereport(ERROR,
(errcode(ERRCODE_PROTOCOL_VIOLATION),
@@ -5609,6 +5617,34 @@ start_apply(XLogRecPtr origin_startpos)
pgstat_report_subscription_error(MySubscription->oid,
MyLogicalRepWorker->type);
+ /*
+ * Insert any pending conflict log tuple under a new transaction.
+ */
+ if (MyLogicalRepWorker->conflict_log_tuple != NULL)
+ {
+ Relation conflictlogrel;
+ ConflictLogDest dest;
+
+ StartTransactionCommand();
+ PushActiveSnapshot(GetTransactionSnapshot());
+
+ /* Open conflict log table and insert the tuple. */
+ conflictlogrel = GetConflictLogTableInfo(&dest);
+ if (ValidateConflictLogTable(conflictlogrel))
+ InsertConflictLogTuple(conflictlogrel);
+ else
+ ereport(WARNING,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("Conflict log table \"%s.%s\" structure changed, skipping insertion",
+ get_namespace_name(RelationGetNamespace(conflictlogrel)),
+ RelationGetRelationName(conflictlogrel)));
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+ table_close(conflictlogrel, RowExclusiveLock);
+
+ PopActiveSnapshot();
+ CommitTransactionCommand();
+ }
+
PG_RE_THROW();
}
}
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index 70f8744b381..226fed9e383 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -139,4 +139,7 @@ extern void ReportApplyConflict(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *remoteslot,
List *conflicttuples);
extern void InitConflictIndexes(ResultRelInfo *relInfo);
+extern Relation GetConflictLogTableInfo(ConflictLogDest *log_dest);
+extern void InsertConflictLogTuple(Relation conflictlogrel);
+extern bool ValidateConflictLogTable(Relation rel);
#endif
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index f081619f151..711c04c7297 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -101,6 +101,9 @@ typedef struct LogicalRepWorker
*/
TransactionId oldest_nonremovable_xid;
+ /* A conflict log tuple that is prepared but not yet inserted. */
+ HeapTuple conflict_log_tuple;
+
/* Stats. */
XLogRecPtr last_lsn;
TimestampTz last_send_time;
@@ -256,6 +259,10 @@ extern PGDLLIMPORT bool InitializingApplyWorker;
extern PGDLLIMPORT List *table_states_not_ready;
+extern XLogRecPtr remote_final_lsn;
+extern TimestampTz remote_commit_ts;
+extern TransactionId remote_xid;
+
extern void logicalrep_worker_attach(int slot);
extern LogicalRepWorker *logicalrep_worker_find(LogicalRepWorkerType wtype,
Oid subid, Oid relid,
--
2.43.0
[text/x-patch] v14-0003-Add-shared-index-for-conflict-log-table-lookup.patch (13.3K, ../../CALDaNm17TRArpX3Axy=2AQBSFbuf-E5Hj0+rNYcMSfXLxenmJQ@mail.gmail.com/4-v14-0003-Add-shared-index-for-conflict-log-table-lookup.patch)
download | inline diff:
From db4d988b4c8554be54d6f807208e4353632e8b2b Mon Sep 17 00:00:00 2001
From: Vignesh C <vignesh21@gmail.com>
Date: Sun, 21 Dec 2025 19:46:01 +0530
Subject: [PATCH v14 3/3] Add shared index for conflict log table lookup
Introduce a dedicated shared unique index on
pg_subscription.subconflictlogrelid to make conflict log table
detection efficient, and index-backed. Previously, IsConflictLogTable()
relied on a full catalog scan of pg_subscription, which was inefficient.
This change adds pg_subscription_conflictrel_index and marks it as a
shared index, matching the shared pg_subscription table, and rewrites
conflict log table detection to use an indexed systable scan.
---
src/backend/catalog/catalog.c | 1 +
src/backend/catalog/pg_publication.c | 12 +----------
src/backend/catalog/pg_subscription.c | 23 +++++++++++++++++++++
src/backend/commands/subscriptioncmds.c | 20 +++++++++++-------
src/backend/replication/logical/conflict.c | 4 +---
src/backend/replication/pgoutput/pgoutput.c | 15 +++++++++++---
src/bin/psql/describe.c | 4 +++-
src/include/catalog/pg_proc.dat | 7 +++++++
src/include/catalog/pg_subscription.h | 1 +
src/test/regress/expected/subscription.out | 7 ++++---
src/test/regress/sql/subscription.sql | 5 +++--
11 files changed, 69 insertions(+), 30 deletions(-)
diff --git a/src/backend/catalog/catalog.c b/src/backend/catalog/catalog.c
index 59caae8f1bc..b95a0a36cd0 100644
--- a/src/backend/catalog/catalog.c
+++ b/src/backend/catalog/catalog.c
@@ -336,6 +336,7 @@ IsSharedRelation(Oid relationId)
relationId == SharedSecLabelObjectIndexId ||
relationId == SubscriptionNameIndexId ||
relationId == SubscriptionObjectIndexId ||
+ relationId == SubscriptionConflictrelIndexId ||
relationId == TablespaceNameIndexId ||
relationId == TablespaceOidIndexId)
return true;
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index 9f84e02b7ef..187eb351f3f 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -86,15 +86,6 @@ check_publication_add_relation(Relation targetrel)
errmsg("cannot add relation \"%s\" to publication",
RelationGetRelationName(targetrel)),
errdetail("This operation is not supported for unlogged tables.")));
-
- /* Can't be conflict log table */
- if (IsConflictLogTable(RelationGetRelid(targetrel)))
- ereport(ERROR,
- (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
- errmsg("cannot add relation \"%s.%s\" to publication",
- get_namespace_name(RelationGetNamespace(targetrel)),
- RelationGetRelationName(targetrel)),
- errdetail("This operation is not supported for conflict log tables.")));
}
/*
@@ -188,8 +179,7 @@ pg_relation_is_publishable(PG_FUNCTION_ARGS)
PG_RETURN_NULL();
/* Subscription conflict log tables are not published */
- result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple)) &&
- !IsConflictLogTable(relid);
+ result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple));
ReleaseSysCache(tuple);
PG_RETURN_BOOL(result);
}
diff --git a/src/backend/catalog/pg_subscription.c b/src/backend/catalog/pg_subscription.c
index 27a9aee1c56..dae44c659f8 100644
--- a/src/backend/catalog/pg_subscription.c
+++ b/src/backend/catalog/pg_subscription.c
@@ -22,6 +22,7 @@
#include "catalog/pg_subscription.h"
#include "catalog/pg_subscription_rel.h"
#include "catalog/pg_type.h"
+#include "commands/subscriptioncmds.h"
#include "miscadmin.h"
#include "storage/lmgr.h"
#include "utils/array.h"
@@ -156,6 +157,28 @@ GetSubscription(Oid subid, bool missing_ok)
return sub;
}
+/*
+ * pg_relation_is_conflict_log_table
+ *
+ * Returns true if the given relation OID is used as a conflict log table
+ * by any subscription, else returns false.
+ */
+Datum
+pg_relation_is_conflict_log_table(PG_FUNCTION_ARGS)
+{
+ Oid relid = PG_GETARG_OID(0);
+ HeapTuple tuple;
+ bool result;
+
+ tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
+ if (!HeapTupleIsValid(tuple))
+ PG_RETURN_NULL();
+
+ result = IsConflictLogTable(relid);
+ ReleaseSysCache(tuple);
+ PG_RETURN_BOOL(result);
+}
+
/*
* Return number of subscriptions defined in given database.
* Used by dropdb() to check if database can indeed be dropped.
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index 65c4c0dd8e4..44821102833 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -51,6 +51,7 @@
#include "storage/lmgr.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/guc.h"
#include "utils/lsyscache.h"
#include "utils/memutils.h"
@@ -3490,27 +3491,32 @@ bool
IsConflictLogTable(Oid relid)
{
Relation rel;
- TableScanDesc scan;
+ ScanKeyData scankey;
+ SysScanDesc scan;
HeapTuple tup;
bool is_clt = false;
rel = table_open(SubscriptionRelationId, AccessShareLock);
- scan = table_beginscan_catalog(rel, 0, NULL);
- while (HeapTupleIsValid(tup = heap_getnext(scan, ForwardScanDirection)))
+ ScanKeyInit(&scankey,
+ Anum_pg_subscription_subconflictlogrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(relid));
+
+ scan = systable_beginscan(rel, SubscriptionConflictrelIndexId,
+ true, NULL, 1, &scankey);
+ while (HeapTupleIsValid(tup = systable_getnext(scan)))
{
Form_pg_subscription subform = (Form_pg_subscription) GETSTRUCT(tup);
- /* Direct Oid comparison from catalog */
- if (OidIsValid(subform->subconflictlogrelid) &&
- subform->subconflictlogrelid == relid)
+ if (OidIsValid(subform->subconflictlogrelid))
{
is_clt = true;
break;
}
}
- table_endscan(scan);
+ systable_endscan(scan);
table_close(rel, AccessShareLock);
return is_clt;
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 5f753cd8042..3aa319f043c 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -300,13 +300,11 @@ GetConflictLogTableInfo(ConflictLogDest *log_dest)
void
InsertConflictLogTuple(Relation conflictlogrel)
{
- int options = HEAP_INSERT_NO_LOGICAL;
-
/* A valid tuple must be prepared and stored in MyLogicalRepWorker. */
Assert(MyLogicalRepWorker->conflict_log_tuple != NULL);
heap_insert(conflictlogrel, MyLogicalRepWorker->conflict_log_tuple,
- GetCurrentCommandId(true), options, NULL);
+ GetCurrentCommandId(true), 0, NULL);
/* Free conflict log tuple. */
heap_freetuple(MyLogicalRepWorker->conflict_log_tuple);
diff --git a/src/backend/replication/pgoutput/pgoutput.c b/src/backend/replication/pgoutput/pgoutput.c
index 787998abb8a..fe37b7fc284 100644
--- a/src/backend/replication/pgoutput/pgoutput.c
+++ b/src/backend/replication/pgoutput/pgoutput.c
@@ -131,6 +131,8 @@ typedef struct RelationSyncEntry
bool schema_sent;
+ bool conflictlogrel; /* is this relation used for conflict logging? */
+
/*
* This will be PUBLISH_GENCOLS_STORED if the relation contains generated
* columns and the 'publish_generated_columns' parameter is set to
@@ -2067,6 +2069,7 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
{
entry->replicate_valid = false;
entry->schema_sent = false;
+ entry->conflictlogrel = false;
entry->include_gencols_type = PUBLISH_GENCOLS_NONE;
entry->streamed_txns = NIL;
entry->pubactions.pubinsert = entry->pubactions.pubupdate =
@@ -2117,6 +2120,7 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
* earlier definition.
*/
entry->schema_sent = false;
+ entry->conflictlogrel = IsConflictLogTable(relid);
entry->include_gencols_type = PUBLISH_GENCOLS_NONE;
list_free(entry->streamed_txns);
entry->streamed_txns = NIL;
@@ -2199,7 +2203,7 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
* If this is a FOR ALL TABLES publication, pick the partition
* root and set the ancestor level accordingly.
*/
- if (pub->alltables)
+ if (pub->alltables && !entry->conflictlogrel)
{
publish = true;
if (pub->pubviaroot && am_partition)
@@ -2225,8 +2229,12 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
{
Oid ancestor;
int level;
- List *ancestors = get_partition_ancestors(relid);
+ List *ancestors;
+
+ /* Conflict log table cannot be a partition */
+ Assert(entry->conflictlogrel == false);
+ ancestors = get_partition_ancestors(relid);
ancestor = GetTopMostAncestorInPublication(pub->oid,
ancestors,
&level);
@@ -2243,7 +2251,8 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
}
if (list_member_oid(pubids, pub->oid) ||
- list_member_oid(schemaPubids, pub->oid) ||
+ (list_member_oid(schemaPubids, pub->oid) &&
+ !entry->conflictlogrel) ||
ancestor_published)
publish = true;
}
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index cc80f0f661c..2d612448241 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -3063,6 +3063,7 @@ describeOneTableDetails(const char *schemaname,
" JOIN pg_catalog.pg_publication_namespace pn ON p.oid = pn.pnpubid\n"
" JOIN pg_catalog.pg_class pc ON pc.relnamespace = pn.pnnspid\n"
"WHERE pc.oid ='%s' and pg_catalog.pg_relation_is_publishable('%s')\n"
+ "AND NOT pg_catalog.pg_relation_is_conflict_log_table('%s'::oid)\n"
"UNION\n"
"SELECT pubname\n"
" , pg_get_expr(pr.prqual, c.oid)\n"
@@ -3082,8 +3083,9 @@ describeOneTableDetails(const char *schemaname,
" , NULL\n"
"FROM pg_catalog.pg_publication p\n"
"WHERE p.puballtables AND pg_catalog.pg_relation_is_publishable('%s')\n"
+ "AND NOT pg_catalog.pg_relation_is_conflict_log_table('%s'::oid)\n"
"ORDER BY 1;",
- oid, oid, oid, oid);
+ oid, oid, oid, oid, oid, oid);
}
else
{
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index fd9448ec7b9..98fe8eee012 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12321,6 +12321,13 @@
prorettype => 'bool', proargtypes => 'regclass',
prosrc => 'pg_relation_is_publishable' },
+# subscriptions
+{ oid => '6123',
+ descr => 'returns whether a relation is a subscription conflict log table',
+ proname => 'pg_relation_is_conflict_log_table', provolatile => 's',
+ prorettype => 'bool', proargtypes => 'regclass',
+ prosrc => 'pg_relation_is_conflict_log_table' },
+
# rls
{ oid => '3298',
descr => 'row security for current context active on table by table oid',
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index 55f4bfa0419..46c446eaf8b 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -123,6 +123,7 @@ DECLARE_TOAST_WITH_MACRO(pg_subscription, 4183, 4184, PgSubscriptionToastTable,
DECLARE_UNIQUE_INDEX_PKEY(pg_subscription_oid_index, 6114, SubscriptionObjectIndexId, pg_subscription, btree(oid oid_ops));
DECLARE_UNIQUE_INDEX(pg_subscription_subname_index, 6115, SubscriptionNameIndexId, pg_subscription, btree(subdbid oid_ops, subname name_ops));
+DECLARE_UNIQUE_INDEX(pg_subscription_conflictrel_index, 6122, SubscriptionConflictrelIndexId, pg_subscription, btree(oid oid_ops, subconflictlogrelid oid_ops));
MAKE_SYSCACHE(SUBSCRIPTIONOID, pg_subscription_oid_index, 4);
MAKE_SYSCACHE(SUBSCRIPTIONNAME, pg_subscription_subname_index, 4);
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index a678471f4c2..81d23a90830 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -635,13 +635,14 @@ EXCEPTION WHEN dependent_objects_still_exist THEN
RAISE NOTICE 'captured expected error: dependent_objects_still_exist';
END $$;
NOTICE: captured expected error: dependent_objects_still_exist
--- PUBLICATION: Verify internal tables are not publishable
--- pg_relation_is_publishable should return false for internal conflict log tables
+-- PUBLICATION: Verify internal tables are publishable
+-- pg_relation_is_publishable should return true for internal conflict log
+-- tables, as it can be published using TABLE publication.
SELECT pg_relation_is_publishable(subconflictlogrelid)
FROM pg_subscription WHERE subname = 'regress_conflict_test1';
pg_relation_is_publishable
----------------------------
- f
+ t
(1 row)
-- CLEANUP: Proper drop reaps the table
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index df0e4649007..d0debe25cb5 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -443,8 +443,9 @@ EXCEPTION WHEN dependent_objects_still_exist THEN
RAISE NOTICE 'captured expected error: dependent_objects_still_exist';
END $$;
--- PUBLICATION: Verify internal tables are not publishable
--- pg_relation_is_publishable should return false for internal conflict log tables
+-- PUBLICATION: Verify internal tables are publishable
+-- pg_relation_is_publishable should return true for internal conflict log
+-- tables, as it can be published using TABLE publication.
SELECT pg_relation_is_publishable(subconflictlogrelid)
FROM pg_subscription WHERE subname = 'regress_conflict_test1';
--
2.43.0
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-22 06:48 vignesh C <vignesh21@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
5 siblings, 0 replies; 912+ messages in thread
From: vignesh C @ 2025-12-22 06:48 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Sat, 20 Dec 2025 at 16:51, Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Sat, Dec 20, 2025 at 3:17 PM vignesh C <vignesh21@gmail.com> wrote:
> >
> > On Tue, 16 Dec 2025 at 09:54, vignesh C <vignesh21@gmail.com> wrote:
> > >
> > > On Sun, 14 Dec 2025 at 21:17, Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > >
> > > > On Sun, Dec 14, 2025 at 3:51 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > > >
> > > > > On Fri, Dec 12, 2025 at 3:04 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > > > > >
> > > > > > On Thu, Dec 11, 2025 at 7:49 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > > > > >
> > > > > > > I was considering the interdependence between the subscription and the
> > > > > > > conflict log table (CLT). IMHO, it would be logical to establish the
> > > > > > > subscription as dependent on the CLT. This way, if someone attempts to
> > > > > > > drop the CLT, the system would recognize the dependency of the
> > > > > > > subscription and prevent the drop unless the subscription is removed
> > > > > > > first or the CASCADE option is used.
> > > > > > >
> > > > > > > However, while investigating this, I encountered an error [1] stating
> > > > > > > that global objects are not supported in this context. This indicates
> > > > > > > that global objects cannot be made dependent on local objects.
> > > > > > >
> > > > > >
> > > > > > What we need here is an equivalent of DEPENDENCY_INTERNAL for database
> > > > > > objects. For example, consider following case:
> > > > > > postgres=# create table t1(c1 int primary key);
> > > > > > CREATE TABLE
> > > > > > postgres=# \d+ t1
> > > > > > Table "public.t1"
> > > > > > Column | Type | Collation | Nullable | Default | Storage |
> > > > > > Compression | Stats target | Description
> > > > > > --------+---------+-----------+----------+---------+---------+-------------+--------------+-------------
> > > > > > c1 | integer | | not null | | plain |
> > > > > > | |
> > > > > > Indexes:
> > > > > > "t1_pkey" PRIMARY KEY, btree (c1)
> > > > > > Publications:
> > > > > > "pub1"
> > > > > > Not-null constraints:
> > > > > > "t1_c1_not_null" NOT NULL "c1"
> > > > > > Access method: heap
> > > > > > postgres=# drop index t1_pkey;
> > > > > > ERROR: cannot drop index t1_pkey because constraint t1_pkey on table
> > > > > > t1 requires it
> > > > > > HINT: You can drop constraint t1_pkey on table t1 instead.
> > > > > >
> > > > > > Here, the PK index is created as part for CREATE TABLE operation and
> > > > > > pk_index is not allowed to be dropped independently.
> > > > > >
> > > > > > > Although making an object dependent on global/shared objects is
> > > > > > > possible for certain types of shared objects [2], this is not our main
> > > > > > > objective.
> > > > > > >
> > > > > >
> > > > > > As per my understanding from the above example, we need something like
> > > > > > that only for shared object subscription and (internally created)
> > > > > > table.
> > > > >
> > > > > Yeah that seems to be exactly what we want, so I tried doing that by
> > > > > recording DEPENDENCY_INTERNAL dependency of CLT on subscription[1] and
> > > > > it is behaving as we want[2]. And while dropping the subscription or
> > > > > altering CLT we can delete internal dependency so that CLT get dropped
> > > > > automatically[3]
> > > > >
> > > > > I will send an updated patch after testing a few more scenarios and
> > > > > fixing other pending issues.
> > > > >
> > > > > [1]
> > > > > + ObjectAddressSet(myself, RelationRelationId, relid);
> > > > > + ObjectAddressSet(subaddr, SubscriptionRelationId, subid);
> > > > > + recordDependencyOn(&myself, &subaddr, DEPENDENCY_INTERNAL);
> > > > >
> > > > >
> > > > > [2]
> > > > > postgres[670778]=# DROP TABLE myschema.conflict_log_history2;
> > > > > ERROR: 2BP01: cannot drop table myschema.conflict_log_history2
> > > > > because subscription sub requires it
> > > > > HINT: You can drop subscription sub instead.
> > > > > LOCATION: findDependentObjects, dependency.c:788
> > > > > postgres[670778]=#
> > > > >
> > > > > [3]
> > > > > ObjectAddressSet(object, SubscriptionRelationId, subid);
> > > > > performDeletion(&object, DROP_CASCADE
> > > > > PERFORM_DELETION_INTERNAL |
> > > > > PERFORM_DELETION_SKIP_ORIGINAL);
> > > > >
> > > > >
> > > >
> > > > Here is the patch which implements the dependency and fixes other
> > > > comments from Shveta.
> > >
> > > Thanks for the changes, the new implementation based on dependency
> > > creates a cycle while dumping:
> > > ./pg_dump -d postgres -f dump1.txt -p 5433
> > > pg_dump: warning: could not resolve dependency loop among these items:
> > > pg_dump: detail: TABLE conflict (ID 225 OID 16397)
> > > pg_dump: detail: SUBSCRIPTION (ID 3484 OID 16396)
> > > pg_dump: detail: POST-DATA BOUNDARY (ID 3491)
> > > pg_dump: detail: TABLE DATA t1 (ID 3485 OID 16384)
> > > pg_dump: detail: PRE-DATA BOUNDARY (ID 3490)
> > >
> > > This can be seen with a simple subscription with conflict_log_table.
> > > This was working fine with the v11 version patch.
> >
> > The attached v13 patch includes the fix for this issue. In addition,
> > it now raises an error when attempting to configure a conflict log
> > table that belongs to a temporary schema or is not a permanent
> > (persistent) relation.
>
> I have updated the patch and here are changes done
> 1. Splitted into 2 patches, 0001- for catalog related changes
> 0002-inserting conflict into the conflict table, Vignesh need to
> rebase the dump and upgrade related patch on this latest changes
> 2. Subscription option changed to conflict_log_destination=(log/table/all/'')
> 3. For internal processing we will use ConflictLogDest enum whereas
> for taking input or storing into catalog we will use string [1].
> 4. As suggested by Sawada San, if conflict_log_destination is 'table'
> we log the information about conflict but don't log the tuple
> details[3]
>
> Pending:
> 1. tap test for conflict insertion
> 2. Still need to work on caching related changes discussed at [2], so
> currently we don't allow conflict log tables to be added to
> publication at all and might change this behavior as discussed at [2]
> and for that we will need to implement the caching.
> 3. Need to add conflict insertion test and doc changes.
> 4. Still need to check on the latest comments from Peter Smith.
>
>
> [1]
> typedef enum ConflictLogDest
> {
> CONFLICT_LOG_DEST_INVALID = 0,
> CONFLICT_LOG_DEST_LOG, /* "log" (default) */
> CONFLICT_LOG_DEST_TABLE, /* "table" */
> CONFLICT_LOG_DEST_ALL /* "all" */
> } ConflictLogDest;
Consider the following scenario. Initially, the subscription was
configured with conflict_log_destination set to a table. As conflicts
occurred, entries were generated and recorded in that table, for
example:
postgres=# SELECT * FROM conflict_log_table_16399;
relid | schemaname | relname | conflict_type | remote_xid |
remote_commit_lsn | remote_commit_ts | remote_origin |
replica_identity | remote_tuple |
local_conflicts
-------+------------+---------+---------------+------------+-------------------+----------------------------------+---------------+------------------+--------------+-------------------------
-------------------------------------------------------------------------
16384 | public | t1 | insert_exists | 765 |
0/0178A718 | 2025-12-22 12:06:57.417789+05:30 | pg_16399 |
| {"c1":1} | {"{\"xid\":\"781\",\"com
mit_ts\":null,\"origin\":null,\"key\":{\"c1\":1},\"tuple\":{\"c1\":1}}"}
16384 | public | t1 | insert_exists | 765 |
0/0178A718 | 2025-12-22 12:06:57.417789+05:30 | pg_16399 |
| {"c1":1} | {"{\"xid\":\"781\",\"com
mit_ts\":null,\"origin\":null,\"key\":{\"c1\":1},\"tuple\":{\"c1\":1}}"}
(2 rows)
Subsequently, the conflict log destination was changed from table to log:
ALTER SUBSCRIPTION sub1 SET (conflict_log_destination = 'log');
As a result, the conflict log table is dropped, and there is no longer
any way to access the previously recorded conflict entries. This
effectively causes the loss of historical conflict data.
It is unclear whether this behavior is desirable or expected. Should
we consider a way to preserve the historical conflict data in this
case?
Regards,
Vignesh
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-22 09:39 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
5 siblings, 1 reply; 912+ messages in thread
From: shveta malik @ 2025-12-22 09:39 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Sat, Dec 20, 2025 at 4:51 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> I have updated the patch and here are changes done
Thank You for the patch. Few comments on 001 alone:
1)
postgres=# create subscription sub1 connection ...' publication pub1
WITH(conflict_log_destination = 'table');
ERROR: could not generate conflict log table "conflict_log_table_16395"
DETAIL: Conflict log tables cannot be created in a temporary namespace.
HINT: Ensure your 'search_path' is set to permanent schema.
Based on such existing errors:
errmsg("cannot create relations in temporary schemas of other sessions")));
errmsg("cannot create temporary relation in non-temporary schema")));
errmsg("cannot create relations in temporary schemas of other sessions")));
Shall we tweak:
--temporary namespace --> temporary schema
--permanent --> non-temporary
2)
postgres=# drop schema shveta cascade;
NOTICE: drop cascades to subscription sub1
ERROR: global objects cannot be deleted by doDeletion
Is this expected? Is the user supposed to see this error?
3)
ConflictLogDestLabels enum starts from 0/INVALID while mapping
ConflictLogDestLabels has values starting from index 1. The index 0
has no value. Thus IMO, wherever we access ConflictLogDestLabels, we
should make a sanity check that index accessed is not
CONFLICT_LOG_DEST_INVALID i.e. opts.logdest !=
CONFLICT_LOG_DEST_INVALID
4)
I find 'Labels' in ConflictLogDestLabels slightly odd. There could be
other names for this variables such as ConflictLogDestValues,
ConflictLogDestStrings or ConflictLogDestNames.
See similar: ConflictTypeNames, SlotInvalidationCauses
5)
+ /*
+ * Strategy for logging replication conflicts:
+ * log - server log only,
+ * table - internal table only,
+ * all - both log and table.
+ */
+ text sublogdestination;
sublogdestination can be confused with regular log_destination. Shall
we rename to subconflictlogdest.
6)
Should the \dRs+ command display the 'Conflict Log Table:' at the end?
This would be similar to how \dRp+ shows 'Tables:', even though the
relation IDs can already be obtained from pg_publication_rel. I think
this would be a useful improvement.
7)
One observation, not sure if it needs any fix, please review and share thoughts.
--CLT created in default public schema present in serach_path
create subscription sub1 connection '..' publication pub1
WITH(conflict_log_destination = 'table');
--Change search path
create schema sch1;
SET search_path=sch1, "$user";
After this, if I create a new sub with destination as 'table', CLT is
generated in sch1. But if I do below:
alter subscription sub1 set (conflict_log_destination='table');
It does not move the table to sch1. This is because
conflict_log_destination is not changed; and as per current
implementation, alter-sub becomes no-op. But search_path is changed.
So what should be the behaviour here?
--let the table be in the old schema, which is currently not in
search_path (existing behaviour)?
--drop the table in the old schema and create a new one present in
search_path?
I could not find a similar case in postgres to compare the behaviour.
If we do
alter subscription sub1 set (conflict_log_destination='log');
alter subscription sub1 set (conflict_log_destination='table');
Then it moves the table to a new schema as internally setting
destination to 'log' drops the table.
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-22 10:24 vignesh C <vignesh21@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
5 siblings, 1 reply; 912+ messages in thread
From: vignesh C @ 2025-12-22 10:24 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Sat, 20 Dec 2025 at 16:51, Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> I have updated the patch and here are changes done
> 1. Splitted into 2 patches, 0001- for catalog related changes
> 0002-inserting conflict into the conflict table, Vignesh need to
> rebase the dump and upgrade related patch on this latest changes
> 2. Subscription option changed to conflict_log_destination=(log/table/all/'')
> 3. For internal processing we will use ConflictLogDest enum whereas
> for taking input or storing into catalog we will use string [1].
> 4. As suggested by Sawada San, if conflict_log_destination is 'table'
> we log the information about conflict but don't log the tuple
> details[3]
Few comments:
1) when a conflict_log_destination is specified as log:
create subscription sub1 connection 'dbname=postgres host=localhost
port=5432' publication pub1 with ( conflict_log_destination='log');
postgres=# select subname, subconflictlogrelid,sublogdestination from
pg_subscription where subname = 'sub4';
subname | subconflictlogrelid | sublogdestination
---------+---------------------+-------------------
sub4 | 0 | log
(1 row)
Currently it displays as 0, instead we can show as NULL in this case
2) can we include displaying of conflict log table also in describe
subscriptions:
+ /* Conflict log destination is supported in v19 and higher */
+ if (pset.sversion >= 190000)
+ {
+ appendPQExpBuffer(&buf,
+ ",
sublogdestination AS \"%s\"\n",
+
gettext_noop("Conflict log destination"));
+ }
3) Can we include pg_ in the conflict table to indicate it is an
internally created table:
+/*
+ * Format the standardized internal conflict log table name for a subscription
+ *
+ * Use the OID to prevent collisions during rename operations.
+ */
+void
+GetConflictLogTableName(char *dest, Oid subid)
+{
+ snprintf(dest, NAMEDATALEN, "conflict_log_table_%u", subid);
+}
4) Can the table be deleted now with the dependency associated between
the table and the subscription?
+ conflictlogrel = table_open(conflictlogrelid, RowExclusiveLock);
+
+ /* Conflict log table is dropped or not accessible. */
+ if (conflictlogrel == NULL)
+ ereport(WARNING,
+ (errcode(ERRCODE_UNDEFINED_TABLE),
+ errmsg("conflict log table with OID
%u does not exist",
+ conflictlogrelid)));
+
+ return conflictlogrel;
5) Should this code be changed to just prepare the conflict log tuple
here, validation and insertion can happen at start_apply if elevel >=
ERROR to avoid ValidateConflictLogTable here as well as at start_apply
function:
+ if (ValidateConflictLogTable(conflictlogrel))
+ {
+ /*
+ * Prepare the conflict log tuple. If the
error level is below
+ * ERROR, insert it immediately. Otherwise,
defer the insertion to
+ * a new transaction after the current one
aborts, ensuring the
+ * insertion of the log tuple is not rolled back.
+ */
+ prepare_conflict_log_tuple(estate,
+
relinfo->ri_RelationDesc,
+
conflictlogrel,
+ type,
+
searchslot,
+
conflicttuples,
+
remoteslot);
+ if (elevel < ERROR)
+ InsertConflictLogTuple(conflictlogrel);
+ }
+ else
+ ereport(WARNING,
+
errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("conflict log table
\"%s.%s\" structure changed, skipping insertion",
+
get_namespace_name(RelationGetNamespace(conflictlogrel)),
+
RelationGetRelationName(conflictlogrel)));
to:
prepare_conflict_log_tuple(estate,
relinfo->ri_RelationDesc,
conflictlogrel,
type,
searchslot,
conflicttuples,
remoteslot);
if (elevel < ERROR)
{
if (ValidateConflictLogTable(conflictlogrel))
InsertConflictLogTuple(conflictlogrel);
else
ereport(WARNING,
errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
errmsg("conflict log table \"%s.%s\" structure changed, skipping insertion",
get_namespace_name(RelationGetNamespace(conflictlogrel)),
RelationGetRelationName(conflictlogrel)));
}
Regards,
Vignesh
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-22 10:31 Dilip Kumar <dilipbalaut@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
5 siblings, 2 replies; 912+ messages in thread
From: Dilip Kumar @ 2025-12-22 10:31 UTC (permalink / raw)
To: vignesh C <vignesh21@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Sat, Dec 20, 2025 at 4:50 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Sat, Dec 20, 2025 at 3:17 PM vignesh C <vignesh21@gmail.com> wrote:
> I have updated the patch and here are changes done
> 1. Splitted into 2 patches, 0001- for catalog related changes
> 0002-inserting conflict into the conflict table, Vignesh need to
> rebase the dump and upgrade related patch on this latest changes
> 2. Subscription option changed to conflict_log_destination=(log/table/all/'')
> 3. For internal processing we will use ConflictLogDest enum whereas
> for taking input or storing into catalog we will use string [1].
> 4. As suggested by Sawada San, if conflict_log_destination is 'table'
> we log the information about conflict but don't log the tuple
> details[3]
>
> Pending:
> 1. tap test for conflict insertion
Done in V15
> 2. Still need to work on caching related changes discussed at [2], so
> currently we don't allow conflict log tables to be added to
> publication at all and might change this behavior as discussed at [2]
> and for that we will need to implement the caching.
Pending
> 3. Need to add conflict insertion test and doc changes.
Done
> 4. Still need to check on the latest comments from Peter Smith.
Done
While planning to send the patch, I have noticed some latest comments
from Shveta and Vignesh, so I will analyze those in the next version.
V15-0004 is Vignesh's patch which is attached as it is and I am going
to review that soon.
--
Regards,
Dilip Kumar
Google
Attachments:
[application/octet-stream] v15-0004-Add-shared-index-for-conflict-log-table-lookup.patch (13.3K, ../../CAFiTN-uKn7mix8BkOOmJQ2cF5yKdfQUg2mX_w9vEC4787VZ_xQ@mail.gmail.com/2-v15-0004-Add-shared-index-for-conflict-log-table-lookup.patch)
download | inline diff:
From efbf8c29221df02c216aba7b9059080da969e174 Mon Sep 17 00:00:00 2001
From: Vignesh C <vignesh21@gmail.com>
Date: Sun, 21 Dec 2025 19:46:01 +0530
Subject: [PATCH v15 4/4] Add shared index for conflict log table lookup
Introduce a dedicated shared unique index on
pg_subscription.subconflictlogrelid to make conflict log table
detection efficient, and index-backed. Previously, IsConflictLogTable()
relied on a full catalog scan of pg_subscription, which was inefficient.
This change adds pg_subscription_conflictrel_index and marks it as a
shared index, matching the shared pg_subscription table, and rewrites
conflict log table detection to use an indexed systable scan.
---
src/backend/catalog/catalog.c | 1 +
src/backend/catalog/pg_publication.c | 12 +----------
src/backend/catalog/pg_subscription.c | 23 +++++++++++++++++++++
src/backend/commands/subscriptioncmds.c | 20 +++++++++++-------
src/backend/replication/logical/conflict.c | 4 +---
src/backend/replication/pgoutput/pgoutput.c | 15 +++++++++++---
src/bin/psql/describe.c | 4 +++-
src/include/catalog/pg_proc.dat | 7 +++++++
src/include/catalog/pg_subscription.h | 1 +
src/test/regress/expected/subscription.out | 7 ++++---
src/test/regress/sql/subscription.sql | 5 +++--
11 files changed, 69 insertions(+), 30 deletions(-)
diff --git a/src/backend/catalog/catalog.c b/src/backend/catalog/catalog.c
index 59caae8f1bc..b95a0a36cd0 100644
--- a/src/backend/catalog/catalog.c
+++ b/src/backend/catalog/catalog.c
@@ -336,6 +336,7 @@ IsSharedRelation(Oid relationId)
relationId == SharedSecLabelObjectIndexId ||
relationId == SubscriptionNameIndexId ||
relationId == SubscriptionObjectIndexId ||
+ relationId == SubscriptionConflictrelIndexId ||
relationId == TablespaceNameIndexId ||
relationId == TablespaceOidIndexId)
return true;
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index 9f84e02b7ef..187eb351f3f 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -86,15 +86,6 @@ check_publication_add_relation(Relation targetrel)
errmsg("cannot add relation \"%s\" to publication",
RelationGetRelationName(targetrel)),
errdetail("This operation is not supported for unlogged tables.")));
-
- /* Can't be conflict log table */
- if (IsConflictLogTable(RelationGetRelid(targetrel)))
- ereport(ERROR,
- (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
- errmsg("cannot add relation \"%s.%s\" to publication",
- get_namespace_name(RelationGetNamespace(targetrel)),
- RelationGetRelationName(targetrel)),
- errdetail("This operation is not supported for conflict log tables.")));
}
/*
@@ -188,8 +179,7 @@ pg_relation_is_publishable(PG_FUNCTION_ARGS)
PG_RETURN_NULL();
/* Subscription conflict log tables are not published */
- result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple)) &&
- !IsConflictLogTable(relid);
+ result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple));
ReleaseSysCache(tuple);
PG_RETURN_BOOL(result);
}
diff --git a/src/backend/catalog/pg_subscription.c b/src/backend/catalog/pg_subscription.c
index 27a9aee1c56..dae44c659f8 100644
--- a/src/backend/catalog/pg_subscription.c
+++ b/src/backend/catalog/pg_subscription.c
@@ -22,6 +22,7 @@
#include "catalog/pg_subscription.h"
#include "catalog/pg_subscription_rel.h"
#include "catalog/pg_type.h"
+#include "commands/subscriptioncmds.h"
#include "miscadmin.h"
#include "storage/lmgr.h"
#include "utils/array.h"
@@ -156,6 +157,28 @@ GetSubscription(Oid subid, bool missing_ok)
return sub;
}
+/*
+ * pg_relation_is_conflict_log_table
+ *
+ * Returns true if the given relation OID is used as a conflict log table
+ * by any subscription, else returns false.
+ */
+Datum
+pg_relation_is_conflict_log_table(PG_FUNCTION_ARGS)
+{
+ Oid relid = PG_GETARG_OID(0);
+ HeapTuple tuple;
+ bool result;
+
+ tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
+ if (!HeapTupleIsValid(tuple))
+ PG_RETURN_NULL();
+
+ result = IsConflictLogTable(relid);
+ ReleaseSysCache(tuple);
+ PG_RETURN_BOOL(result);
+}
+
/*
* Return number of subscriptions defined in given database.
* Used by dropdb() to check if database can indeed be dropped.
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index 05dcb5e3fe4..b3694375191 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -52,6 +52,7 @@
#include "storage/lmgr.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/guc.h"
#include "utils/lsyscache.h"
#include "utils/memutils.h"
@@ -3494,27 +3495,32 @@ bool
IsConflictLogTable(Oid relid)
{
Relation rel;
- TableScanDesc scan;
+ ScanKeyData scankey;
+ SysScanDesc scan;
HeapTuple tup;
bool is_clt = false;
rel = table_open(SubscriptionRelationId, AccessShareLock);
- scan = table_beginscan_catalog(rel, 0, NULL);
- while (HeapTupleIsValid(tup = heap_getnext(scan, ForwardScanDirection)))
+ ScanKeyInit(&scankey,
+ Anum_pg_subscription_subconflictlogrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(relid));
+
+ scan = systable_beginscan(rel, SubscriptionConflictrelIndexId,
+ true, NULL, 1, &scankey);
+ while (HeapTupleIsValid(tup = systable_getnext(scan)))
{
Form_pg_subscription subform = (Form_pg_subscription) GETSTRUCT(tup);
- /* Direct Oid comparison from catalog */
- if (OidIsValid(subform->subconflictlogrelid) &&
- subform->subconflictlogrelid == relid)
+ if (OidIsValid(subform->subconflictlogrelid))
{
is_clt = true;
break;
}
}
- table_endscan(scan);
+ systable_endscan(scan);
table_close(rel, AccessShareLock);
return is_clt;
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 7cc07a64427..0b6e3f4a2c8 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -294,13 +294,11 @@ GetConflictLogTableInfo(ConflictLogDest *log_dest)
void
InsertConflictLogTuple(Relation conflictlogrel)
{
- int options = HEAP_INSERT_NO_LOGICAL;
-
/* A valid tuple must be prepared and stored in MyLogicalRepWorker. */
Assert(MyLogicalRepWorker->conflict_log_tuple != NULL);
heap_insert(conflictlogrel, MyLogicalRepWorker->conflict_log_tuple,
- GetCurrentCommandId(true), options, NULL);
+ GetCurrentCommandId(true), 0, NULL);
/* Free conflict log tuple. */
heap_freetuple(MyLogicalRepWorker->conflict_log_tuple);
diff --git a/src/backend/replication/pgoutput/pgoutput.c b/src/backend/replication/pgoutput/pgoutput.c
index 787998abb8a..fe37b7fc284 100644
--- a/src/backend/replication/pgoutput/pgoutput.c
+++ b/src/backend/replication/pgoutput/pgoutput.c
@@ -131,6 +131,8 @@ typedef struct RelationSyncEntry
bool schema_sent;
+ bool conflictlogrel; /* is this relation used for conflict logging? */
+
/*
* This will be PUBLISH_GENCOLS_STORED if the relation contains generated
* columns and the 'publish_generated_columns' parameter is set to
@@ -2067,6 +2069,7 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
{
entry->replicate_valid = false;
entry->schema_sent = false;
+ entry->conflictlogrel = false;
entry->include_gencols_type = PUBLISH_GENCOLS_NONE;
entry->streamed_txns = NIL;
entry->pubactions.pubinsert = entry->pubactions.pubupdate =
@@ -2117,6 +2120,7 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
* earlier definition.
*/
entry->schema_sent = false;
+ entry->conflictlogrel = IsConflictLogTable(relid);
entry->include_gencols_type = PUBLISH_GENCOLS_NONE;
list_free(entry->streamed_txns);
entry->streamed_txns = NIL;
@@ -2199,7 +2203,7 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
* If this is a FOR ALL TABLES publication, pick the partition
* root and set the ancestor level accordingly.
*/
- if (pub->alltables)
+ if (pub->alltables && !entry->conflictlogrel)
{
publish = true;
if (pub->pubviaroot && am_partition)
@@ -2225,8 +2229,12 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
{
Oid ancestor;
int level;
- List *ancestors = get_partition_ancestors(relid);
+ List *ancestors;
+
+ /* Conflict log table cannot be a partition */
+ Assert(entry->conflictlogrel == false);
+ ancestors = get_partition_ancestors(relid);
ancestor = GetTopMostAncestorInPublication(pub->oid,
ancestors,
&level);
@@ -2243,7 +2251,8 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
}
if (list_member_oid(pubids, pub->oid) ||
- list_member_oid(schemaPubids, pub->oid) ||
+ (list_member_oid(schemaPubids, pub->oid) &&
+ !entry->conflictlogrel) ||
ancestor_published)
publish = true;
}
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index cc80f0f661c..2d612448241 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -3063,6 +3063,7 @@ describeOneTableDetails(const char *schemaname,
" JOIN pg_catalog.pg_publication_namespace pn ON p.oid = pn.pnpubid\n"
" JOIN pg_catalog.pg_class pc ON pc.relnamespace = pn.pnnspid\n"
"WHERE pc.oid ='%s' and pg_catalog.pg_relation_is_publishable('%s')\n"
+ "AND NOT pg_catalog.pg_relation_is_conflict_log_table('%s'::oid)\n"
"UNION\n"
"SELECT pubname\n"
" , pg_get_expr(pr.prqual, c.oid)\n"
@@ -3082,8 +3083,9 @@ describeOneTableDetails(const char *schemaname,
" , NULL\n"
"FROM pg_catalog.pg_publication p\n"
"WHERE p.puballtables AND pg_catalog.pg_relation_is_publishable('%s')\n"
+ "AND NOT pg_catalog.pg_relation_is_conflict_log_table('%s'::oid)\n"
"ORDER BY 1;",
- oid, oid, oid, oid);
+ oid, oid, oid, oid, oid, oid);
}
else
{
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index fd9448ec7b9..98fe8eee012 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12321,6 +12321,13 @@
prorettype => 'bool', proargtypes => 'regclass',
prosrc => 'pg_relation_is_publishable' },
+# subscriptions
+{ oid => '6123',
+ descr => 'returns whether a relation is a subscription conflict log table',
+ proname => 'pg_relation_is_conflict_log_table', provolatile => 's',
+ prorettype => 'bool', proargtypes => 'regclass',
+ prosrc => 'pg_relation_is_conflict_log_table' },
+
# rls
{ oid => '3298',
descr => 'row security for current context active on table by table oid',
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index 55f4bfa0419..46c446eaf8b 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -123,6 +123,7 @@ DECLARE_TOAST_WITH_MACRO(pg_subscription, 4183, 4184, PgSubscriptionToastTable,
DECLARE_UNIQUE_INDEX_PKEY(pg_subscription_oid_index, 6114, SubscriptionObjectIndexId, pg_subscription, btree(oid oid_ops));
DECLARE_UNIQUE_INDEX(pg_subscription_subname_index, 6115, SubscriptionNameIndexId, pg_subscription, btree(subdbid oid_ops, subname name_ops));
+DECLARE_UNIQUE_INDEX(pg_subscription_conflictrel_index, 6122, SubscriptionConflictrelIndexId, pg_subscription, btree(oid oid_ops, subconflictlogrelid oid_ops));
MAKE_SYSCACHE(SUBSCRIPTIONOID, pg_subscription_oid_index, 4);
MAKE_SYSCACHE(SUBSCRIPTIONNAME, pg_subscription_subname_index, 4);
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index 4ab58d90925..92423c83197 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -635,13 +635,14 @@ EXCEPTION WHEN dependent_objects_still_exist THEN
RAISE NOTICE 'captured expected error: dependent_objects_still_exist';
END $$;
NOTICE: captured expected error: dependent_objects_still_exist
--- PUBLICATION: Verify internal tables are not publishable
--- pg_relation_is_publishable should return false for internal conflict log tables
+-- PUBLICATION: Verify internal tables are publishable
+-- pg_relation_is_publishable should return true for internal conflict log
+-- tables, as it can be published using TABLE publication.
SELECT pg_relation_is_publishable(subconflictlogrelid)
FROM pg_subscription WHERE subname = 'regress_conflict_test1';
pg_relation_is_publishable
----------------------------
- f
+ t
(1 row)
-- CLEANUP: Proper drop reaps the table
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index 3359ff8be5c..b4b98c9a178 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -443,8 +443,9 @@ EXCEPTION WHEN dependent_objects_still_exist THEN
RAISE NOTICE 'captured expected error: dependent_objects_still_exist';
END $$;
--- PUBLICATION: Verify internal tables are not publishable
--- pg_relation_is_publishable should return false for internal conflict log tables
+-- PUBLICATION: Verify internal tables are publishable
+-- pg_relation_is_publishable should return true for internal conflict log
+-- tables, as it can be published using TABLE publication.
SELECT pg_relation_is_publishable(subconflictlogrelid)
FROM pg_subscription WHERE subname = 'regress_conflict_test1';
--
2.49.0
[application/octet-stream] v15-0002-Implement-the-conflict-insertion-infrastructure-.patch (34.3K, ../../CAFiTN-uKn7mix8BkOOmJQ2cF5yKdfQUg2mX_w9vEC4787VZ_xQ@mail.gmail.com/3-v15-0002-Implement-the-conflict-insertion-infrastructure-.patch)
download | inline diff:
From 86f60bb37fde35f64b44ff3c1025e8070a18d613 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Sat, 20 Dec 2025 15:20:09 +0530
Subject: [PATCH v15 2/4] Implement the conflict insertion infrastructure into
the conflict log table
Note: A single remote tuple may conflict with multiple local tuples when conflict type
is CT_MULTIPLE_UNIQUE_CONFLICTS, so for handling this case we create a single row in
conflict log table with respect to each remote conflict tuple even if it conflicts with
multiple local tuples and we store the multiple conflict tuples as a single JSON array
element in format as
[ { "xid": "1001", "commit_ts": "...", "origin": "...", "tuple": {...} }, ... ]
We can extract the elements of the local tuples from the conflict log table row
as given in below example.
SELECT remote_xid, relname, remote_origin, local_conflicts[1] ->> 'xid' AS local_xid,
local_conflicts[1] ->> 'tuple' AS local_tuple
FROM myschema.conflict_log_history2;
remote_xid | relname | remote_origin | local_xid | local_tuple
------------+----------+---------------+-----------+---------------------
760 | test | pg_16406 | 771 | {"a":1,"b":10}
765 | conf_tab | pg_16406 | 775 | {"a":2,"b":2,"c":2}
---
src/backend/replication/logical/conflict.c | 596 ++++++++++++++++++-
src/backend/replication/logical/launcher.c | 1 +
src/backend/replication/logical/worker.c | 32 +-
src/include/replication/conflict.h | 17 +-
src/include/replication/worker_internal.h | 7 +
src/test/subscription/t/037_conflict_dest.pl | 181 ++++++
6 files changed, 800 insertions(+), 34 deletions(-)
create mode 100644 src/test/subscription/t/037_conflict_dest.pl
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 16695592265..7cc07a64427 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -15,13 +15,22 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/tableam.h"
+#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "pgstat.h"
#include "replication/conflict.h"
#include "replication/worker_internal.h"
#include "storage/lmgr.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/lsyscache.h"
+#include "utils/pg_lsn.h"
+#include "utils/jsonb.h"
+
+#define MAX_LOCAL_CONFLICT_INFO_ATTRS 5
static const char *const ConflictTypeNames[] = {
[CT_INSERT_EXISTS] = "insert_exists",
@@ -50,8 +59,27 @@ static char *build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *localslot,
TupleTableSlot *remoteslot,
Oid indexoid);
+static void build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull);
static char *build_index_value_desc(EState *estate, Relation localrel,
TupleTableSlot *slot, Oid indexoid);
+static Datum tuple_table_slot_to_json_datum(TupleTableSlot *slot);
+static Datum tuple_table_slot_to_indextup_json(EState *estate,
+ Relation localrel,
+ Oid replica_index,
+ TupleTableSlot *slot);
+static TupleDesc build_conflict_tupledesc(void);
+static Datum build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples);
+static void prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot);
/*
* Get the xmin and commit timestamp data (origin and timestamp) associated
@@ -105,11 +133,19 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
ConflictType type, TupleTableSlot *searchslot,
TupleTableSlot *remoteslot, List *conflicttuples)
{
- Relation localrel = relinfo->ri_RelationDesc;
- StringInfoData err_detail;
+ Relation localrel = relinfo->ri_RelationDesc;
+ StringInfoData err_detail;
+ ConflictLogDest dest;
+ Relation conflictlogrel;
initStringInfo(&err_detail);
+ /*
+ * Get both the conflict log destination and the opened conflict log
+ * relation for insertion.
+ */
+ conflictlogrel = GetConflictLogTableInfo(&dest);
+
/* Form errdetail message by combining conflicting tuples information. */
foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
errdetail_apply_conflict(estate, relinfo, type, searchslot,
@@ -120,15 +156,63 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
conflicttuple->ts,
&err_detail);
+ /* Insert to table if destination is 'table' or 'all' */
+ if (conflictlogrel)
+ {
+ Assert(dest == CONFLICT_LOG_DEST_TABLE ||
+ dest == CONFLICT_LOG_DEST_ALL);
+
+ if (ValidateConflictLogTable(conflictlogrel))
+ {
+ /*
+ * Prepare the conflict log tuple. If the error level is below
+ * ERROR, insert it immediately. Otherwise, defer the insertion to
+ * a new transaction after the current one aborts, ensuring the
+ * insertion of the log tuple is not rolled back.
+ */
+ prepare_conflict_log_tuple(estate,
+ relinfo->ri_RelationDesc,
+ conflictlogrel,
+ type,
+ searchslot,
+ conflicttuples,
+ remoteslot);
+ if (elevel < ERROR)
+ InsertConflictLogTuple(conflictlogrel);
+ }
+
+ table_close(conflictlogrel, RowExclusiveLock);
+ }
+
pgstat_report_subscription_conflict(MySubscription->oid, type);
- ereport(elevel,
- errcode_apply_conflict(type),
- errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
- get_namespace_name(RelationGetNamespace(localrel)),
- RelationGetRelationName(localrel),
- ConflictTypeNames[type]),
- errdetail_internal("%s", err_detail.data));
+ /* Decide what detail to show in server logs. */
+ if (dest == CONFLICT_LOG_DEST_LOG || dest == CONFLICT_LOG_DEST_ALL)
+ {
+ /* Standard reporting with full internal details. */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail_internal("%s", err_detail.data));
+ }
+ else
+ {
+ /*
+ * 'table' only: Report the error msg but omit raw tuple data from
+ * server logs since it's already captured in the internal table.
+ */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail("Conflict details logged to internal table with OID %u.",
+ MySubscription->conflictrelid));
+ }
}
/*
@@ -162,6 +246,143 @@ InitConflictIndexes(ResultRelInfo *relInfo)
relInfo->ri_onConflictArbiterIndexes = uniqueIndexes;
}
+/*
+ * GetConflictLogTableInfo
+ *
+ * Fetches conflict logging metadata from the cached MySubscription pointer.
+ * Sets the destination enum in *log_dest and, if applicable, opens and
+ * returns the relation handle for the internal log table.
+ */
+Relation
+GetConflictLogTableInfo(ConflictLogDest *log_dest)
+{
+ Oid conflictlogrelid;
+ Relation conflictlogrel = NULL;
+
+ /*
+ * Convert the text log destination to the internal enum. MySubscription
+ * already contains the data from pg_subscription.
+ */
+ *log_dest = GetLogDestination(MySubscription->logdestination);
+ conflictlogrelid = MySubscription->conflictrelid;
+
+ /* If destination is 'log' only, no table to open. */
+ if (*log_dest == CONFLICT_LOG_DEST_LOG)
+ return NULL;
+
+ Assert(OidIsValid(conflictlogrelid));
+
+ conflictlogrel = table_open(conflictlogrelid, RowExclusiveLock);
+
+ /* Conflict log table is dropped or not accessible. */
+ if (conflictlogrel == NULL)
+ ereport(WARNING,
+ (errcode(ERRCODE_UNDEFINED_TABLE),
+ errmsg("conflict log table with OID %u does not exist",
+ conflictlogrelid)));
+
+ return conflictlogrel;
+}
+
+/*
+ * InsertConflictLogTuple
+ *
+ * Insert conflict log tuple into the conflict log table. It uses
+ * HEAP_INSERT_NO_LOGICAL to explicitly block logical decoding of the tuple
+ * inserted into the conflict log table.
+ */
+void
+InsertConflictLogTuple(Relation conflictlogrel)
+{
+ int options = HEAP_INSERT_NO_LOGICAL;
+
+ /* A valid tuple must be prepared and stored in MyLogicalRepWorker. */
+ Assert(MyLogicalRepWorker->conflict_log_tuple != NULL);
+
+ heap_insert(conflictlogrel, MyLogicalRepWorker->conflict_log_tuple,
+ GetCurrentCommandId(true), options, NULL);
+
+ /* Free conflict log tuple. */
+ heap_freetuple(MyLogicalRepWorker->conflict_log_tuple);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+}
+
+/*
+ * ValidateConflictLogTable - Validate conflict log table schema
+ *
+ * Checks whether the table definition including its column names, data
+ * types, and column ordering meet the requirements for conflict log
+ * table.
+ */
+bool
+ValidateConflictLogTable(Relation rel)
+{
+ Relation pg_attribute;
+ HeapTuple atup;
+ ScanKeyData scankey;
+ SysScanDesc scan;
+ int attcnt = 0;
+ bool tbl_ok = true;
+
+ pg_attribute = table_open(AttributeRelationId, AccessShareLock);
+ ScanKeyInit(&scankey,
+ Anum_pg_attribute_attrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationGetRelid(rel)));
+
+ scan = systable_beginscan(pg_attribute, AttributeRelidNumIndexId, true,
+ SnapshotSelf, 1, &scankey);
+
+ /* We only need to check up to MAX_CONFLICT_ATTR_NUM attributes */
+ while (HeapTupleIsValid(atup = systable_getnext(scan)))
+ {
+ const ConflictLogColumnDef *expected;
+ int schema_idx;
+ Form_pg_attribute attForm = (Form_pg_attribute) GETSTRUCT(atup);
+
+ /* Skip system columns and dropped columns */
+ if (attForm->attnum < 1 || attForm->attisdropped)
+ continue;
+
+ attcnt++;
+
+ /* attnum 1 corresponds to index 0 in ConflictLogSchema */
+ schema_idx = attForm->attnum - 1;
+
+ /* Check against the central schema definition */
+ if (schema_idx >= MAX_CONFLICT_ATTR_NUM)
+ {
+ /* Found an extra column beyond the required set */
+ tbl_ok = false;
+ break;
+ }
+
+ expected = &ConflictLogSchema[schema_idx];
+
+ if (attForm->atttypid != expected->atttypid ||
+ strcmp(NameStr(attForm->attname), expected->attname) != 0)
+ {
+ tbl_ok = false;
+ break;
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_attribute, AccessShareLock);
+
+ if (attcnt != MAX_CONFLICT_ATTR_NUM || !tbl_ok)
+ {
+ ereport(WARNING,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("conflict log table \"%s.%s\" structure changed, skipping insertion",
+ get_namespace_name(RelationGetNamespace(rel)),
+ RelationGetRelationName(rel)));
+ return false;
+ }
+
+ return true;
+}
+
/*
* Add SQLSTATE error code to the current conflict report.
*/
@@ -472,6 +693,40 @@ build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
return tuple_value.data;
}
+/*
+ * Helper function to extract the "raw" index key Datums and their null flags
+ * from a TupleTableSlot, given an already open index descriptor.
+ * This is the reusable core logic.
+ */
+static void
+build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull)
+{
+ TupleTableSlot *tableslot = slot;
+
+ /*
+ * If the slot is a virtual slot, copy it into a heap tuple slot as
+ * FormIndexDatum only works with heap tuple slots.
+ */
+ if (TTS_IS_VIRTUAL(slot))
+ {
+ /* Slot is created within the EState's tuple table */
+ tableslot = table_slot_create(localrel, &estate->es_tupleTable);
+ tableslot = ExecCopySlot(tableslot, slot);
+ }
+
+ /*
+ * Initialize ecxt_scantuple for potential use in FormIndexDatum
+ */
+ GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+
+ /* Form the index datums */
+ FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values,
+ isnull);
+}
+
/*
* Helper functions to construct a string describing the contents of an index
* entry. See BuildIndexValueDescription for details.
@@ -487,41 +742,318 @@ build_index_value_desc(EState *estate, Relation localrel, TupleTableSlot *slot,
Relation indexDesc;
Datum values[INDEX_MAX_KEYS];
bool isnull[INDEX_MAX_KEYS];
- TupleTableSlot *tableslot = slot;
- if (!tableslot)
+ if (!slot)
return NULL;
Assert(CheckRelationOidLockedByMe(indexoid, RowExclusiveLock, true));
indexDesc = index_open(indexoid, NoLock);
- /*
- * If the slot is a virtual slot, copy it into a heap tuple slot as
- * FormIndexDatum only works with heap tuple slots.
- */
- if (TTS_IS_VIRTUAL(slot))
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+
+ index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+
+ index_close(indexDesc, NoLock);
+
+ return index_value;
+}
+
+/*
+ * tuple_table_slot_to_json_datum
+ *
+ * Helper function to convert a TupleTableSlot to Jsonb.
+ */
+static Datum
+tuple_table_slot_to_json_datum(TupleTableSlot *slot)
+{
+ HeapTuple tuple;
+ Datum datum;
+ Datum json;
+
+ Assert(slot != NULL);
+
+ tuple = ExecCopySlotHeapTuple(slot);
+ datum = heap_copy_tuple_as_datum(tuple, slot->tts_tupleDescriptor);
+
+ json = DirectFunctionCall1(row_to_json, datum);
+ heap_freetuple(tuple);
+
+ return json;
+}
+
+/*
+ * tuple_table_slot_to_indextup_json
+ *
+ * Fetch replica identity key from the tuple table slot and convert into a
+ * jsonb datum.
+ */
+static Datum
+tuple_table_slot_to_indextup_json(EState *estate, Relation localrel,
+ Oid indexid, TupleTableSlot *slot)
+{
+ Relation indexDesc;
+ Datum values[INDEX_MAX_KEYS];
+ bool isnull[INDEX_MAX_KEYS];
+ HeapTuple tuple;
+ TupleDesc tupdesc;
+ Datum datum;
+
+ Assert(slot != NULL);
+
+ Assert(CheckRelationOidLockedByMe(indexid, RowExclusiveLock, true));
+
+ indexDesc = index_open(indexid, NoLock);
+
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+ tupdesc = RelationGetDescr(indexDesc);
+
+ /* Bless the tupdesc so it can be looked up by row_to_json. */
+ BlessTupleDesc(tupdesc);
+
+ /* Form the replica identity tuple. */
+ tuple = heap_form_tuple(tupdesc, values, isnull);
+ datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ index_close(indexDesc, NoLock);
+
+ /* Convert to a JSONB datum. */
+ return DirectFunctionCall1(row_to_json, datum);
+}
+
+static TupleDesc
+build_conflict_tupledesc(void)
+{
+ TupleDesc tupdesc;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ for (int i = 0; i < MAX_LOCAL_CONFLICT_INFO_ATTRS; i++)
+ TupleDescInitEntry(tupdesc, (AttrNumber) (i + 1),
+ LocalConflictSchema[i].attname,
+ LocalConflictSchema[i].atttypid,
+ -1, 0);
+
+ BlessTupleDesc(tupdesc);
+
+ return tupdesc;
+}
+
+/*
+ * Builds the local conflicts JSONB array column from the list of
+ * ConflictTupleInfo objects.
+ *
+ * Example output structure:
+ * [ { "xid": "1001", "commit_ts": "...", "origin": "...", "tuple": {...} }, ... ]
+ */
+static Datum
+build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples)
+{
+ ListCell *lc;
+ List *json_datums = NIL; /* List to hold the row_to_json results (type json) */
+ Datum *json_datum_array;
+ bool *json_null_array;
+ Datum json_array_datum;
+ int num_conflicts;
+ int i;
+ int16 typlen;
+ bool typbyval;
+ char typalign;
+ TupleDesc tupdesc;
+
+ /* Build local conflicts tuple descriptor. */
+ tupdesc = build_conflict_tupledesc();
+
+ /* Process local conflict tuple list and prepare an array of JSON. */
+ foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
{
- tableslot = table_slot_create(localrel, &estate->es_tupleTable);
- tableslot = ExecCopySlot(tableslot, slot);
+ Datum values[MAX_LOCAL_CONFLICT_INFO_ATTRS] = {0};
+ bool nulls[MAX_LOCAL_CONFLICT_INFO_ATTRS] = {0};
+ char *origin_name = NULL;
+ HeapTuple tuple;
+ Datum json_datum;
+ int attno;
+
+ attno = 0;
+ values[attno++] = TransactionIdGetDatum(conflicttuple->xmin);
+
+ if (conflicttuple->ts)
+ values[attno++] = TimestampTzGetDatum(conflicttuple->ts);
+ else
+ nulls[attno++] = true;
+
+ if (conflicttuple->origin != InvalidRepOriginId)
+ replorigin_by_oid(conflicttuple->origin, true, &origin_name);
+
+ /* Store empty string if origin name for the tuple is NULL. */
+ if (origin_name != NULL)
+ values[attno++] = CStringGetTextDatum(origin_name);
+ else
+ nulls[attno++] = true;
+
+ /*
+ * Add the conflicting key values in the case of a unique constraint
+ * violation.
+ */
+ if (conflict_type == CT_INSERT_EXISTS ||
+ conflict_type == CT_UPDATE_EXISTS ||
+ conflict_type == CT_MULTIPLE_UNIQUE_CONFLICTS)
+ {
+ Oid indexoid = conflicttuple->indexoid;
+
+ Assert(OidIsValid(indexoid) && conflicttuple->slot &&
+ CheckRelationOidLockedByMe(indexoid, RowExclusiveLock,
+ true));
+ values[attno++] =
+ tuple_table_slot_to_indextup_json(estate, rel,
+ indexoid,
+ conflicttuple->slot);
+ }
+ else
+ nulls[attno++] = true;
+
+ /* Convert conflicting tuple to JSON datum. */
+ if (conflicttuple->slot)
+ values[attno] = tuple_table_slot_to_json_datum(conflicttuple->slot);
+ else
+ nulls[attno] = true;
+
+ Assert(attno + 1 == MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ tuple = heap_form_tuple(tupdesc, values, nulls);
+
+ json_datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ /*
+ * Build the higher level JSON datum in format described in function
+ * header.
+ */
+ json_datum = DirectFunctionCall1(row_to_json, json_datum);
+
+ /* Done with the temporary tuple. */
+ heap_freetuple(tuple);
+
+ /* Add to the array element. */
+ json_datums = lappend(json_datums, (void *) json_datum);
}
- /*
- * Initialize ecxt_scantuple for potential use in FormIndexDatum when
- * index expressions are present.
- */
- GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+ num_conflicts = list_length(json_datums);
- /*
- * The values/nulls arrays passed to BuildIndexValueDescription should be
- * the results of FormIndexDatum, which are the "raw" input to the index
- * AM.
- */
- FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values, isnull);
+ json_datum_array = (Datum *) palloc(num_conflicts * sizeof(Datum));
+ json_null_array = (bool *) palloc0(num_conflicts * sizeof(bool));
- index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+ i = 0;
+ foreach(lc, json_datums)
+ {
+ json_datum_array[i] = (Datum) lfirst(lc);
+ i++;
+ }
- index_close(indexDesc, NoLock);
+ /* Construct the json[] array Datum. */
+ get_typlenbyvalalign(JSONOID, &typlen, &typbyval, &typalign);
+ json_array_datum = PointerGetDatum(construct_array(json_datum_array,
+ num_conflicts,
+ JSONOID,
+ typlen,
+ typbyval,
+ typalign));
+ pfree(json_datum_array);
+ pfree(json_null_array);
+
+ return json_array_datum;
+}
- return index_value;
+/*
+ * prepare_conflict_log_tuple
+ *
+ * This routine prepares a tuple detailing a conflict encountered during
+ * logical replication. The prepared tuple will be stored in
+ * MyLogicalRepWorker->conflict_log_tuple which should be inserted into the
+ * conflict log table by calling InsertConflictLogTuple.
+ */
+static void
+prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot)
+{
+ Datum values[MAX_CONFLICT_ATTR_NUM] = {0};
+ bool nulls[MAX_CONFLICT_ATTR_NUM] = {0};
+ int attno;
+ char *remote_origin = NULL;
+ MemoryContext oldctx;
+
+ Assert(MyLogicalRepWorker->conflict_log_tuple == NULL);
+
+ /* Populate the values and nulls arrays. */
+ attno = 0;
+ values[attno++] = ObjectIdGetDatum(RelationGetRelid(rel));
+
+ values[attno++] =
+ CStringGetTextDatum(get_namespace_name(RelationGetNamespace(rel)));
+
+ values[attno++] = CStringGetTextDatum(RelationGetRelationName(rel));
+
+ values[attno++] = CStringGetTextDatum(ConflictTypeNames[conflict_type]);
+
+ if (TransactionIdIsValid(remote_xid))
+ values[attno++] = TransactionIdGetDatum(remote_xid);
+ else
+ nulls[attno++] = true;
+
+ values[attno++] = LSNGetDatum(remote_final_lsn);
+
+ if (remote_commit_ts > 0)
+ values[attno++] = TimestampTzGetDatum(remote_commit_ts);
+ else
+ nulls[attno++] = true;
+
+ if (replorigin_session_origin != InvalidRepOriginId)
+ replorigin_by_oid(replorigin_session_origin, true, &remote_origin);
+
+ if (remote_origin != NULL)
+ values[attno++] = CStringGetTextDatum(remote_origin);
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(searchslot))
+ {
+ Oid replica_index = GetRelationIdentityOrPK(rel);
+
+ /*
+ * If the table has a valid replica identity index, build the index
+ * json datum from key value. Otherwise, construct it from the complete
+ * tuple in REPLICA IDENTITY FULL cases.
+ */
+ if (OidIsValid(replica_index))
+ values[attno++] = tuple_table_slot_to_indextup_json(estate, rel,
+ replica_index,
+ searchslot);
+ else
+ values[attno++] = tuple_table_slot_to_json_datum(searchslot);
+ }
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(remoteslot))
+ values[attno++] = tuple_table_slot_to_json_datum(remoteslot);
+ else
+ nulls[attno++] = true;
+
+ values[attno] = build_local_conflicts_json_array(estate, rel,
+ conflict_type,
+ conflicttuples);
+
+ Assert(attno + 1 == MAX_CONFLICT_ATTR_NUM);
+
+ oldctx = MemoryContextSwitchTo(ApplyContext);
+ MyLogicalRepWorker->conflict_log_tuple =
+ heap_form_tuple(RelationGetDescr(conflictlogrel), values, nulls);
+ MemoryContextSwitchTo(oldctx);
}
diff --git a/src/backend/replication/logical/launcher.c b/src/backend/replication/logical/launcher.c
index 3991e1495d4..bc7e1d9ebde 100644
--- a/src/backend/replication/logical/launcher.c
+++ b/src/backend/replication/logical/launcher.c
@@ -477,6 +477,7 @@ retry:
worker->oldest_nonremovable_xid = retain_dead_tuples
? MyReplicationSlot->data.xmin
: InvalidTransactionId;
+ worker->conflict_log_tuple = NULL;
worker->last_lsn = InvalidXLogRecPtr;
TIMESTAMP_NOBEGIN(worker->last_send_time);
TIMESTAMP_NOBEGIN(worker->last_recv_time);
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index fc64476a9ef..05912a6050e 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -482,7 +482,9 @@ static bool MySubscriptionValid = false;
static List *on_commit_wakeup_workers_subids = NIL;
bool in_remote_transaction = false;
-static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+TransactionId remote_xid = InvalidTransactionId;
+TimestampTz remote_commit_ts = 0;
/* fields valid only when processing streamed transaction */
static bool in_streamed_transaction = false;
@@ -1219,6 +1221,8 @@ apply_handle_begin(StringInfo s)
set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
remote_final_lsn = begin_data.final_lsn;
+ remote_commit_ts = begin_data.committime;
+ remote_xid = begin_data.xid;
maybe_start_skipping_changes(begin_data.final_lsn);
@@ -1745,6 +1749,10 @@ apply_handle_stream_start(StringInfo s)
/* extract XID of the top-level transaction */
stream_xid = logicalrep_read_stream_start(s, &first_segment);
+ remote_xid = stream_xid;
+ remote_final_lsn = InvalidXLogRecPtr;
+ remote_commit_ts = 0;
+
if (!TransactionIdIsValid(stream_xid))
ereport(ERROR,
(errcode(ERRCODE_PROTOCOL_VIOLATION),
@@ -5609,6 +5617,28 @@ start_apply(XLogRecPtr origin_startpos)
pgstat_report_subscription_error(MySubscription->oid,
MyLogicalRepWorker->type);
+ /*
+ * Insert any pending conflict log tuple under a new transaction.
+ */
+ if (MyLogicalRepWorker->conflict_log_tuple != NULL)
+ {
+ Relation conflictlogrel;
+ ConflictLogDest dest;
+
+ StartTransactionCommand();
+ PushActiveSnapshot(GetTransactionSnapshot());
+
+ /* Open conflict log table and insert the tuple. */
+ conflictlogrel = GetConflictLogTableInfo(&dest);
+ if (ValidateConflictLogTable(conflictlogrel))
+ InsertConflictLogTuple(conflictlogrel);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+ table_close(conflictlogrel, RowExclusiveLock);
+
+ PopActiveSnapshot();
+ CommitTransactionCommand();
+ }
+
PG_RE_THROW();
}
}
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index 70f8744b381..5f313b7a976 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -126,9 +126,21 @@ static const ConflictLogColumnDef ConflictLogSchema[] =
{ .attname = "local_conflicts", .atttypid = JSONARRAYOID }
};
-/* Define the count using the array size */
#define MAX_CONFLICT_ATTR_NUM (sizeof(ConflictLogSchema) / sizeof(ConflictLogSchema[0]))
+/* Schema for the elements within the 'local_conflicts' JSON array */
+static const ConflictLogColumnDef LocalConflictSchema[] =
+{
+ { .attname = "xid", .atttypid = XIDOID },
+ { .attname = "commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "origin", .atttypid = TEXTOID },
+ { .attname = "key", .atttypid = JSONOID },
+ { .attname = "tuple", .atttypid = JSONOID }
+};
+
+#define MAX_LOCAL_CONFLICT_INFO_ATTRS \
+ (sizeof(LocalConflictSchema) / sizeof(LocalConflictSchema[0]))
+
extern bool GetTupleTransactionInfo(TupleTableSlot *localslot,
TransactionId *xmin,
RepOriginId *localorigin,
@@ -139,4 +151,7 @@ extern void ReportApplyConflict(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *remoteslot,
List *conflicttuples);
extern void InitConflictIndexes(ResultRelInfo *relInfo);
+extern Relation GetConflictLogTableInfo(ConflictLogDest *log_dest);
+extern void InsertConflictLogTuple(Relation conflictlogrel);
+extern bool ValidateConflictLogTable(Relation rel);
#endif
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index f081619f151..711c04c7297 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -101,6 +101,9 @@ typedef struct LogicalRepWorker
*/
TransactionId oldest_nonremovable_xid;
+ /* A conflict log tuple that is prepared but not yet inserted. */
+ HeapTuple conflict_log_tuple;
+
/* Stats. */
XLogRecPtr last_lsn;
TimestampTz last_send_time;
@@ -256,6 +259,10 @@ extern PGDLLIMPORT bool InitializingApplyWorker;
extern PGDLLIMPORT List *table_states_not_ready;
+extern XLogRecPtr remote_final_lsn;
+extern TimestampTz remote_commit_ts;
+extern TransactionId remote_xid;
+
extern void logicalrep_worker_attach(int slot);
extern LogicalRepWorker *logicalrep_worker_find(LogicalRepWorkerType wtype,
Oid subid, Oid relid,
diff --git a/src/test/subscription/t/037_conflict_dest.pl b/src/test/subscription/t/037_conflict_dest.pl
new file mode 100644
index 00000000000..a6f2a4d94bb
--- /dev/null
+++ b/src/test/subscription/t/037_conflict_dest.pl
@@ -0,0 +1,181 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+# Test conflicts in logical replication
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+###############################
+# Setup
+###############################
+
+# Create a publisher node
+my $node_publisher = PostgreSQL::Test::Cluster->new('publisher');
+$node_publisher->init(allows_streaming => 'logical');
+$node_publisher->start;
+
+# Create a subscriber node
+my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
+$node_subscriber->init(allows_streaming => 'logical');
+$node_subscriber->start;
+
+# Create a table on publisher
+$node_publisher->safe_psql('postgres',
+ "CREATE TABLE conf_tab (a int PRIMARY KEY, b int UNIQUE, c int UNIQUE);");
+
+$node_publisher->safe_psql('postgres',
+ "CREATE TABLE conf_tab_2 (a int PRIMARY KEY, b int UNIQUE, c int UNIQUE);"
+);
+
+# Create same table on subscriber
+$node_subscriber->safe_psql('postgres',
+ "CREATE TABLE conf_tab (a int PRIMARY key, b int UNIQUE, c int UNIQUE);");
+
+$node_subscriber->safe_psql(
+ 'postgres', qq[
+ CREATE TABLE conf_tab_2 (a int PRIMARY KEY, b int, c int, unique(a,b)) PARTITION BY RANGE (a);
+ CREATE TABLE conf_tab_2_p1 PARTITION OF conf_tab_2 FOR VALUES FROM (MINVALUE) TO (100);
+]);
+
+# Setup logical replication
+my $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
+$node_publisher->safe_psql('postgres',
+ "CREATE PUBLICATION pub_tab FOR TABLE conf_tab, conf_tab_2");
+
+# Create the subscription
+my $appname = 'sub_tab';
+$node_subscriber->safe_psql(
+ 'postgres',
+ "CREATE SUBSCRIPTION sub_tab
+ CONNECTION '$publisher_connstr application_name=$appname'
+ PUBLICATION pub_tab WITH (conflict_log_destination=table)");
+
+# Wait for initial table sync to finish
+$node_subscriber->wait_for_subscription_sync($node_publisher, $appname);
+
+##################################################
+# INSERT data on Pub and Sub
+##################################################
+
+# Insert data in the publisher table
+$node_publisher->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (1,1,1);");
+
+# Insert data in the subscriber table
+$node_subscriber->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (2,2,2), (3,3,3), (4,4,4);");
+
+###############################################################################
+# Test conflict insertion into the internal conflict log table
+###############################################################################
+
+$node_subscriber->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (10, 10, 10);");
+
+# Get the internally generated table name
+my $subid = $node_subscriber->safe_psql('postgres',
+ "SELECT oid FROM pg_subscription WHERE subname = 'sub_tab';");
+my $conflict_table = "conflict_log_table_$subid";
+
+$node_publisher->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (10, 20, 30);");
+
+# Wait for the conflict to be logged
+my $log_check = $node_subscriber->poll_query_until(
+ 'postgres',
+ "SELECT count(*) > 0 FROM $conflict_table;"
+);
+
+is($log_check, 1, 'Conflict was successfully logged to the internal table');
+
+my $json_query = qq[
+ SELECT string_agg((unnested.j::json)->'key'->>'a', ',')
+ FROM (
+ SELECT unnest(local_conflicts) AS j
+ FROM $conflict_table
+ ) AS unnested;
+];
+
+my $all_keys = $node_subscriber->safe_psql('postgres', $json_query);
+
+# Verify that '10' is present in the resulting string
+like($all_keys, qr/10/, 'Verified that key 10 exists in the local_conflicts log');
+
+pass('Conflict type and data successfully validated in internal table');
+
+# Final cleanup for subsequent bidirectional tests in the script
+$node_subscriber->safe_psql('postgres', "TRUNCATE conf_tab;");
+
+###############################################################################
+# Test Case: update_missing
+###############################################################################
+
+# Sync a row, then delete it locally on subscriber
+$node_publisher->safe_psql('postgres', "INSERT INTO conf_tab VALUES (50, 50, 50);");
+$node_publisher->wait_for_catchup($appname);
+$node_subscriber->safe_psql('postgres', "DELETE FROM conf_tab WHERE a = 50;");
+
+# Trigger conflict by updating that row on publisher
+$node_publisher->safe_psql('postgres', "UPDATE conf_tab SET b = 500 WHERE a = 50;");
+
+# Wait for the apply worker to detect the missing row and log it
+$node_subscriber->poll_query_until('postgres',
+ "SELECT count(*) > 0 FROM $conflict_table WHERE conflict_type = 'update_missing';"
+) or die "Timed out waiting for update_missing conflict";
+
+my $upd_miss_check = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM $conflict_table WHERE conflict_type = 'update_missing';");
+is($upd_miss_check, 1, 'Verified update_missing conflict logged to internal table');
+
+$node_subscriber->safe_psql('postgres', "TRUNCATE conf_tab;");
+
+###############################################################################
+# Test Case: insert_exists (via secondary unique index)
+###############################################################################
+
+# 1. Subscriber has a row with b=100
+$node_subscriber->safe_psql('postgres', "INSERT INTO conf_tab VALUES (100, 100, 100);");
+
+# 2. Publisher inserts a NEW PK (101) but a DUPLICATE 'b' (100)
+$node_publisher->safe_psql('postgres', "INSERT INTO conf_tab VALUES (101, 100, 101);");
+
+# 3. Verify it appears as 'insert_exists' in your log table
+$node_subscriber->poll_query_until('postgres',
+ "SELECT count(*) > 0 FROM $conflict_table WHERE conflict_type = 'insert_exists' AND local_conflicts::text LIKE '%100%';"
+) or die "Timed out waiting for secondary index insert_exists conflict";
+
+pass('Logged insert_exists triggered by secondary unique index violation');
+
+$node_subscriber->safe_psql('postgres', "TRUNCATE conf_tab;");
+
+###############################################################################
+# CASE 3: Switching Destination to 'log' (Server Log Verification)
+###############################################################################
+
+# Switch destination
+$node_subscriber->safe_psql('postgres',
+ "ALTER SUBSCRIPTION sub_tab SET (conflict_log_destination = 'all');");
+
+$node_subscriber->safe_psql('postgres', "TRUNCATE TABLE $conflict_table;");
+# Trigger a conflict for server log (insert_exists)
+$node_subscriber->safe_psql('postgres', "INSERT INTO conf_tab VALUES (600, 600, 600);");
+$node_publisher->safe_psql('postgres', "INSERT INTO conf_tab VALUES (600, 700, 700);");
+
+# Wait for table log
+$node_subscriber->poll_query_until('postgres', "SELECT count(*) > 0 FROM $conflict_table;")
+ or die "Timed out waiting for insert_exists conflict";
+
+# Check subscriber server log
+my $log_found = $node_subscriber->wait_for_log(
+ qr/conflict detected on relation "public.conf_tab": conflict=insert_exists/
+);
+ok($log_found, 'Conflict correctly directed to server stderr log');
+
+# Verify table count DID NOT increase for this conflict
+my $table_check = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM $conflict_table WHERE local_conflicts::text LIKE '%600%';");
+is($table_check, 1, 'Table log was bypassed when destination set to log');
+
+done_testing();
--
2.49.0
[application/octet-stream] v15-0003-Doccumentation-patch.patch (11.3K, ../../CAFiTN-uKn7mix8BkOOmJQ2cF5yKdfQUg2mX_w9vEC4787VZ_xQ@mail.gmail.com/4-v15-0003-Doccumentation-patch.patch)
download | inline diff:
From 2c7b9233c531a395745b9725f6c2006cfa355d0f Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Sun, 21 Dec 2025 18:51:57 +0530
Subject: [PATCH v15 3/4] Doccumentation patch
---
doc/src/sgml/logical-replication.sgml | 122 +++++++++++++++++++++-
doc/src/sgml/ref/alter_subscription.sgml | 12 ++-
doc/src/sgml/ref/create_subscription.sgml | 36 +++++++
3 files changed, 164 insertions(+), 6 deletions(-)
diff --git a/doc/src/sgml/logical-replication.sgml b/doc/src/sgml/logical-replication.sgml
index b3faaa675ef..5544f9beb02 100644
--- a/doc/src/sgml/logical-replication.sgml
+++ b/doc/src/sgml/logical-replication.sgml
@@ -253,7 +253,11 @@
The subscription is added using <link linkend="sql-createsubscription"><command>CREATE SUBSCRIPTION</command></link> and
can be stopped/resumed at any time using the
<link linkend="sql-altersubscription"><command>ALTER SUBSCRIPTION</command></link> command and removed using
- <link linkend="sql-dropsubscription"><command>DROP SUBSCRIPTION</command></link>.
+ <link linkend="sql-dropsubscription"><command>DROP SUBSCRIPTION</command></link>. When the
+ <literal>conflict_log_destination</literal> parameter is set to <literal>table</literal>
+ or <literal>all</literal>, the system automatically manages a dedicated conflict
+ storage table. This table is dropped automatically when the subscription is
+ removed via <command>DROP SUBSCRIPTION</command>.
</para>
<para>
@@ -289,6 +293,16 @@
option of <command>CREATE SUBSCRIPTION</command> for details.
</para>
+ <para>
+ Conflicts that occur during replication are typically logged as plain text
+ in the server log, which can be difficult for automated monitoring and
+ analysis. The <command>CREATE SUBSCRIPTION</command> command provides the
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination">
+ <literal>conflict_log_destination</literal></link> option to record detailed
+ conflict information in a structured, queryable format, significantly
+ improving post-mortem analysis and operational visibility of the replication
+ setup by logging to a system-managed table.
+
<sect2 id="logical-replication-subscription-slot">
<title>Logical Replication Slot Management</title>
@@ -2006,9 +2020,14 @@ Publications:
</para>
<para>
- Additional logging is triggered, and the conflict statistics are collected (displayed in the
- <link linkend="monitoring-pg-stat-subscription-stats"><structname>pg_stat_subscription_stats</structname></link> view)
- in the following <firstterm>conflict</firstterm> cases:
+ Additional logging is triggered, and the conflict statistics are collected
+ (displayed in the <link linkend="monitoring-pg-stat-subscription-stats">
+ <structname>pg_stat_subscription_stats</structname></link> view)
+ in the following <firstterm>conflict</firstterm> cases. If the subscription
+ was created with the <literal>conflict_log_destination</literal> set to
+ <literal>table</literal>, detailed conflict information is also inserted
+ into an internally managed table named <literal>conflict_log_table_<subscription_oid></literal>,
+ providing a structured record of all conflicts.
<variablelist>
<varlistentry id="conflict-insert-exists" xreflabel="insert_exists">
<term><literal>insert_exists</literal></term>
@@ -2117,6 +2136,92 @@ Publications:
log.
</para>
+ <para>
+ When the <literal>conflict_log_destination</literal> is set to
+ <literal>table</literal>, the system automatically creates a new table with
+ a predefined schema to log conflict details. This table is created in the
+ owner's schema, is owned by the subscription owner, and logs system fields.
+ The schema of this table is detailed in
+ <xref linkend="logical-replication-conflict-log-schema"/>.
+ </para>
+
+ <table id="logical-replication-conflict-log-schema">
+ <title>Conflict Log History Table Schema</title>
+ <tgroup cols="3">
+ <thead>
+ <row>
+ <entry>Column</entry>
+ <entry>Type</entry>
+ <entry>Description</entry>
+ </row>
+ </thead>
+ <tbody>
+ <row>
+ <entry><literal>relid</literal></entry>
+ <entry><type>oid</type></entry>
+ <entry>The OID of the local table where the conflict occurred.</entry>
+ </row>
+ <row>
+ <entry><literal>schemaname</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The schema name of the conflicting table.</entry>
+ </row>
+ <row>
+ <entry><literal>relname</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The name of the conflicting table.</entry>
+ </row>
+ <row>
+ <entry><literal>conflict_type</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The type of conflict that occurred (e.g., <literal>insert_exists</literal>).</entry>
+ </row>
+ <row>
+ <entry><literal>remote_xid</literal></entry>
+ <entry><type>xid</type></entry>
+ <entry>The remote transaction ID that caused the conflict.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_commit_lsn</literal></entry>
+ <entry><type>pg_lsn</type></entry>
+ <entry>The final LSN of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_commit_ts</literal></entry>
+ <entry><type>timestamptz</type></entry>
+ <entry>The remote commit timestamp of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_origin</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The origin of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_tuple</literal></entry>
+ <entry><type>json</type></entry>
+ <entry>The JSON representation of the incoming remote row that caused the conflict.</entry>
+ </row>
+ <row>
+ <entry><literal>local_conflicts</literal></entry>
+ <entry><type>json[]</type></entry>
+ <entry>
+ An array of JSON objects representing the local state for each conflict attempt.
+ Each object includes the local transaction ID (<literal>xid</literal>), commit
+ timestamp (<literal>commit_ts</literal>), origin (<literal>origin</literal>),
+ conflicting key data (<literal>key</literal>), and the full local row
+ image (<literal>tuple</literal>).
+ </entry>
+ </row>
+ </tbody>
+ </tgroup>
+ </table>
+
+ <para>
+ The conflicting row data, including the original local tuple and
+ the remote tuple, is stored in <type>JSON</type> columns (<literal>local_tuple</literal>
+ and <literal>remote_tuple</literal>) for flexible querying and analysis.
+ </para>
+
<para>
The log format for logical replication conflicts is as follows:
<synopsis>
@@ -2412,6 +2517,15 @@ CONTEXT: processing remote data for replication origin "pg_16395" during "INSER
key or replica identity defined for it.
</para>
</listitem>
+
+ <listitem>
+ <para>
+ The internal table automatically created when <literal>conflict_log_destination</literal>
+ is set to <literal>table</literal> or <literal>all</literal> is excluded from
+ logical replication. It will not be published, even if a publication on the
+ subscriber is defined using <literal>FOR ALL TABLES</literal>.
+ </para>
+ </listitem>
</itemizedlist>
</sect1>
diff --git a/doc/src/sgml/ref/alter_subscription.sgml b/doc/src/sgml/ref/alter_subscription.sgml
index 27c06439f4f..2f3bb0618f5 100644
--- a/doc/src/sgml/ref/alter_subscription.sgml
+++ b/doc/src/sgml/ref/alter_subscription.sgml
@@ -280,8 +280,9 @@ ALTER SUBSCRIPTION <replaceable class="parameter">name</replaceable> RENAME TO <
<link linkend="sql-createsubscription-params-with-origin"><literal>origin</literal></link>,
<link linkend="sql-createsubscription-params-with-failover"><literal>failover</literal></link>,
<link linkend="sql-createsubscription-params-with-two-phase"><literal>two_phase</literal></link>,
- <link linkend="sql-createsubscription-params-with-retain-dead-tuples"><literal>retain_dead_tuples</literal></link>, and
- <link linkend="sql-createsubscription-params-with-max-retention-duration"><literal>max_retention_duration</literal></link>.
+ <link linkend="sql-createsubscription-params-with-retain-dead-tuples"><literal>retain_dead_tuples</literal></link>,
+ <link linkend="sql-createsubscription-params-with-max-retention-duration"><literal>max_retention_duration</literal></link> and,
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>.
Only a superuser can set <literal>password_required = false</literal>.
</para>
@@ -339,6 +340,13 @@ ALTER SUBSCRIPTION <replaceable class="parameter">name</replaceable> RENAME TO <
<quote><literal>pg_conflict_detection</literal></quote>, created to retain
dead tuples for conflict detection, will be dropped.
</para>
+
+ <para>
+ When switching <literal>conflict_log_destination</literal> to <literal>table</literal>,
+ the system will ensure the internal logging table exists. If switching away
+ from <literal>table</literal>, the logging stops, but the previously recorded
+ data remains until the subscription is dropped.
+ </para>
</listitem>
</varlistentry>
diff --git a/doc/src/sgml/ref/create_subscription.sgml b/doc/src/sgml/ref/create_subscription.sgml
index 197be0c6f6b..3fa891bc4ae 100644
--- a/doc/src/sgml/ref/create_subscription.sgml
+++ b/doc/src/sgml/ref/create_subscription.sgml
@@ -274,6 +274,42 @@ CREATE SUBSCRIPTION <replaceable class="parameter">subscription_name</replaceabl
</listitem>
</varlistentry>
+ <varlistentry id="sql-createsubscription-params-with-conflict-log-destination">
+ <term><literal>conflict_log_destination</literal> (<type>enum</type>)</term>
+ <listitem>
+ <para>
+ Specifies the destination for recording logical replication conflicts.
+ The supported values are <literal>log</literal>, <literal>table</literal>,
+ and <literal>all</literal>. The default is <literal>log</literal>.
+ </para>
+ <para>
+ The available destinations are:
+ <itemizedlist>
+ <listitem>
+ <para>
+ <literal>log</literal>: Conflict details are recorded in the server log.
+ This is the default behavior.
+ </para>
+ </listitem>
+ <listitem>
+ <para>
+ <literal>table</literal>: The system automatically creates a structured table named
+ <literal>pg_conflict_<subid></literal> in the subscription owner's schema.
+ This allows for easy querying and analysis of conflicts. This table is
+ automatically dropped when the subscription is removed.
+ </para>
+ </listitem>
+ <listitem>
+ <para>
+ <literal>all</literal>: Records the conflict information to both the server log
+ and the dedicated conflict table.
+ </para>
+ </listitem>
+ </itemizedlist>
+ </para>
+ </listitem>
+ </varlistentry>
+
<varlistentry id="sql-createsubscription-params-with-streaming">
<term><literal>streaming</literal> (<type>enum</type>)</term>
<listitem>
--
2.49.0
[application/octet-stream] v15-0001-Add-configurable-conflict-log-table-for-Logical-.patch (97.9K, ../../CAFiTN-uKn7mix8BkOOmJQ2cF5yKdfQUg2mX_w9vEC4787VZ_xQ@mail.gmail.com/5-v15-0001-Add-configurable-conflict-log-table-for-Logical-.patch)
download | inline diff:
From 959b80c114cc30e1244e598c1e421049f21f1940 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 17 Dec 2025 11:53:47 +0530
Subject: [PATCH v15 1/4] Add configurable conflict log table for Logical
Replication
This patch adds a feature to provide a structured, queryable record of all
logical replication conflicts. The current approach of logging conflicts as
plain text in the server logs makes it difficult to query, analyze, and
use for external monitoring and automation.
This patch addresses these limitations by introducing a configurable
conflict_log_destination=(log/table/all) option in the CREATE SUBSCRIPTION
command.
If user choose to log into the table the table will automatically created while
creating the subscription with internal name i.e. conflict_log_table_$subid$. The
table will be created in the current search path and table would be automatically
dropped while dropping the subscription.
The conflict details, including the original and remote tuples, are stored in JSON
columns, providing a flexible format to accommodate different table schemas.
The log table captures essential attributes such as local and remote transaction IDs,
LSNs, commit timestamps, and conflict type, providing a complete record for post-mortem
analysis.
This feature will make logical replication conflicts easier to monitor and manage,
significantly improving the overall resilience and operability of replication setups.
The conflict log tables will not be included in a publication, even if the publication
is configured to include ALL TABLES or ALL TABLES IN SCHEMA.
---
src/backend/catalog/pg_publication.c | 26 +-
src/backend/catalog/pg_subscription.c | 7 +
src/backend/commands/subscriptioncmds.c | 337 ++++++++++++++++++++-
src/bin/psql/describe.c | 21 +-
src/bin/psql/tab-complete.in.c | 8 +-
src/include/catalog/pg_subscription.h | 11 +
src/include/commands/subscriptioncmds.h | 5 +
src/include/replication/conflict.h | 50 +++
src/test/regress/expected/subscription.out | 328 ++++++++++++++------
src/test/regress/sql/subscription.sql | 109 +++++++
src/tools/pgindent/typedefs.list | 1 +
11 files changed, 799 insertions(+), 104 deletions(-)
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index 7aa3f179924..9f84e02b7ef 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -31,6 +31,7 @@
#include "catalog/pg_publication_rel.h"
#include "catalog/pg_type.h"
#include "commands/publicationcmds.h"
+#include "commands/subscriptioncmds.h"
#include "funcapi.h"
#include "utils/array.h"
#include "utils/builtins.h"
@@ -85,6 +86,15 @@ check_publication_add_relation(Relation targetrel)
errmsg("cannot add relation \"%s\" to publication",
RelationGetRelationName(targetrel)),
errdetail("This operation is not supported for unlogged tables.")));
+
+ /* Can't be conflict log table */
+ if (IsConflictLogTable(RelationGetRelid(targetrel)))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot add relation \"%s.%s\" to publication",
+ get_namespace_name(RelationGetNamespace(targetrel)),
+ RelationGetRelationName(targetrel)),
+ errdetail("This operation is not supported for conflict log tables.")));
}
/*
@@ -145,6 +155,13 @@ is_publishable_class(Oid relid, Form_pg_class reltuple)
/*
* Another variant of is_publishable_class(), taking a Relation.
+ *
+ * Note: Conflict log tables are not publishable. However, we intentionally
+ * skip this check here because this function is called for every change and
+ * performing this check during every change publication is costly. To ensure
+ * unpublishable entries are ignored without incurring performance overhead,
+ * tuples inserted into the conflict log table uses the HEAP_INSERT_NO_LOGICAL
+ * flag. This allows the decoding layer to bypass these entries automatically.
*/
bool
is_publishable_relation(Relation rel)
@@ -169,7 +186,10 @@ pg_relation_is_publishable(PG_FUNCTION_ARGS)
tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
if (!HeapTupleIsValid(tuple))
PG_RETURN_NULL();
- result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple));
+
+ /* Subscription conflict log tables are not published */
+ result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple)) &&
+ !IsConflictLogTable(relid);
ReleaseSysCache(tuple);
PG_RETURN_BOOL(result);
}
@@ -890,7 +910,9 @@ GetAllPublicationRelations(char relkind, bool pubviaroot)
Form_pg_class relForm = (Form_pg_class) GETSTRUCT(tuple);
Oid relid = relForm->oid;
+ /* Subscription conflict log tables are not published */
if (is_publishable_class(relid, relForm) &&
+ !IsConflictLogTable(relid) &&
!(relForm->relispartition && pubviaroot))
result = lappend_oid(result, relid);
}
@@ -1018,7 +1040,7 @@ GetSchemaPublicationRelations(Oid schemaid, PublicationPartOpt pub_partopt)
Oid relid = relForm->oid;
char relkind;
- if (!is_publishable_class(relid, relForm))
+ if (!is_publishable_class(relid, relForm) || IsConflictLogTable(relid))
continue;
relkind = get_rel_relkind(relid);
diff --git a/src/backend/catalog/pg_subscription.c b/src/backend/catalog/pg_subscription.c
index ad6fbd77ffd..27a9aee1c56 100644
--- a/src/backend/catalog/pg_subscription.c
+++ b/src/backend/catalog/pg_subscription.c
@@ -106,6 +106,7 @@ GetSubscription(Oid subid, bool missing_ok)
sub->retaindeadtuples = subform->subretaindeadtuples;
sub->maxretention = subform->submaxretention;
sub->retentionactive = subform->subretentionactive;
+ sub->conflictrelid = subform->subconflictlogrelid;
/* Get conninfo */
datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
@@ -141,6 +142,12 @@ GetSubscription(Oid subid, bool missing_ok)
Anum_pg_subscription_suborigin);
sub->origin = TextDatumGetCString(datum);
+ /* Get conflict log destination */
+ datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
+ tup,
+ Anum_pg_subscription_sublogdestination);
+ sub->logdestination = TextDatumGetCString(datum);
+
/* Is the subscription owner a superuser? */
sub->ownersuperuser = superuser_arg(sub->owner);
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index abbcaff0838..05dcb5e3fe4 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -15,25 +15,30 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/htup_details.h"
#include "access/table.h"
#include "access/twophase.h"
#include "access/xact.h"
#include "catalog/catalog.h"
#include "catalog/dependency.h"
+#include "catalog/heap.h"
#include "catalog/indexing.h"
#include "catalog/namespace.h"
#include "catalog/objectaccess.h"
#include "catalog/objectaddress.h"
+#include "catalog/pg_am_d.h"
#include "catalog/pg_authid_d.h"
#include "catalog/pg_database_d.h"
#include "catalog/pg_subscription.h"
#include "catalog/pg_subscription_rel.h"
#include "catalog/pg_type.h"
+#include "commands/comment.h"
#include "commands/defrem.h"
#include "commands/event_trigger.h"
#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "miscadmin.h"
#include "nodes/makefuncs.h"
#include "pgstat.h"
@@ -51,6 +56,7 @@
#include "utils/lsyscache.h"
#include "utils/memutils.h"
#include "utils/pg_lsn.h"
+#include "utils/regproc.h"
#include "utils/syscache.h"
/*
@@ -75,6 +81,7 @@
#define SUBOPT_MAX_RETENTION_DURATION 0x00008000
#define SUBOPT_LSN 0x00010000
#define SUBOPT_ORIGIN 0x00020000
+#define SUBOPT_CONFLICT_LOG_DESTINATION 0x00040000
/* check if the 'val' has 'bits' set */
#define IsSet(val, bits) (((val) & (bits)) == (bits))
@@ -103,6 +110,7 @@ typedef struct SubOpts
bool retaindeadtuples;
int32 maxretention;
char *origin;
+ ConflictLogDest logdest;
XLogRecPtr lsn;
} SubOpts;
@@ -135,7 +143,8 @@ static List *merge_publications(List *oldpublist, List *newpublist, bool addpub,
static void ReportSlotConnectionError(List *rstates, Oid subid, char *slotname, char *err);
static void CheckAlterSubOption(Subscription *sub, const char *option,
bool slot_needs_update, bool isTopLevel);
-
+static Oid create_conflict_log_table(Oid subid, char *subname, Oid namespaceId,
+ char *conflictrel);
/*
* Common option parsing function for CREATE and ALTER SUBSCRIPTION commands.
@@ -191,6 +200,8 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->maxretention = 0;
if (IsSet(supported_opts, SUBOPT_ORIGIN))
opts->origin = pstrdup(LOGICALREP_ORIGIN_ANY);
+ if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DESTINATION))
+ opts->logdest = CONFLICT_LOG_DEST_LOG;
/* Parse options */
foreach(lc, stmt_options)
@@ -402,6 +413,28 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->specified_opts |= SUBOPT_LSN;
opts->lsn = lsn;
}
+ else if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DESTINATION) &&
+ strcmp(defel->defname, "conflict_log_destination") == 0)
+ {
+ char *val;
+ ConflictLogDest dest;
+
+ if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_LOG_DESTINATION))
+ errorConflictingDefElem(defel, pstate);
+
+ val = defGetString(defel);
+
+ dest = GetLogDestination(val);
+
+ if (dest == CONFLICT_LOG_DEST_INVALID)
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("unrecognized conflict_log_destination value: \"%s\"", val),
+ errhint("Valid values are \"log\", \"table\", and \"all\".")));
+
+ opts->logdest = dest;
+ opts->specified_opts |= SUBOPT_CONFLICT_LOG_DESTINATION;
+ }
else
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
@@ -612,7 +645,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
SUBOPT_DISABLE_ON_ERR | SUBOPT_PASSWORD_REQUIRED |
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
- SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN);
+ SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_DESTINATION);
parse_subscription_options(pstate, stmt->options, supported_opts, &opts);
/*
@@ -747,6 +781,40 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
values[Anum_pg_subscription_suborigin - 1] =
CStringGetTextDatum(opts.origin);
+ /* Always set the destination, default will be log. */
+ values[Anum_pg_subscription_sublogdestination - 1] =
+ CStringGetTextDatum(ConflictLogDestLabels[opts.logdest]);
+
+ /*
+ * If the conflict log destination includes 'table', generate an internal
+ * name using the subscription OID and determine the target namespace based
+ * on the current search path. Store the namespace OID and the conflict log
+ * format in the pg_subscription catalog tuple., then physically create
+ * the table.
+ */
+ if (opts.logdest == CONFLICT_LOG_DEST_TABLE ||
+ opts.logdest == CONFLICT_LOG_DEST_ALL)
+ {
+ char conflict_table_name[NAMEDATALEN];
+ Oid namespaceId, logrelid;
+
+ GetConflictLogTableName(conflict_table_name, subid);
+ namespaceId = RangeVarGetCreationNamespace(
+ makeRangeVar(NULL, conflict_table_name, -1));
+
+ /* Store the Oid returned from creation. */
+ logrelid = create_conflict_log_table(subid, stmt->subname, namespaceId,
+ conflict_table_name);
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(logrelid);
+ }
+ else
+ {
+ /* Destination is "log"; no table is needed. */
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(InvalidOid);
+ }
+
tup = heap_form_tuple(RelationGetDescr(rel), values, nulls);
/* Insert tuple into catalog. */
@@ -1410,7 +1478,8 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
SUBOPT_MAX_RETENTION_DURATION |
- SUBOPT_ORIGIN);
+ SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_DESTINATION);
parse_subscription_options(pstate, stmt->options,
supported_opts, &opts);
@@ -1665,6 +1734,72 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
origin = opts.origin;
}
+ if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_LOG_DESTINATION))
+ {
+ ConflictLogDest old_dest =
+ GetLogDestination(sub->logdestination);
+
+ if (opts.logdest != old_dest)
+ {
+ bool want_table =
+ (opts.logdest == CONFLICT_LOG_DEST_TABLE ||
+ opts.logdest == CONFLICT_LOG_DEST_ALL);
+ bool has_oldtable =
+ (old_dest == CONFLICT_LOG_DEST_TABLE ||
+ old_dest == CONFLICT_LOG_DEST_ALL);
+
+ values[Anum_pg_subscription_sublogdestination - 1] =
+ CStringGetTextDatum(ConflictLogDestLabels[opts.logdest]);
+ replaces[Anum_pg_subscription_sublogdestination - 1] = true;
+
+ if (want_table && !has_oldtable)
+ {
+ char relname[NAMEDATALEN];
+ Oid nspid;
+ Oid relid;
+
+ GetConflictLogTableName(relname, subid);
+ nspid = RangeVarGetCreationNamespace(makeRangeVar(
+ NULL, relname, -1));
+
+ relid = create_conflict_log_table(subid, sub->name,
+ nspid, relname);
+
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(relid);
+ replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
+ true;
+ }
+ else if (!want_table && has_oldtable)
+ {
+ ObjectAddress object;
+
+ /*
+ * Conflict log tables are recorded as internal
+ * dependencies of the subscription. Drop the
+ * table if it is not required anymore to avoid
+ * stale or orphaned relations.
+ *
+ * XXX: At present, only conflict log tables are
+ * managed this way. In future if we introduce
+ * additional internal dependencies, we may need
+ * a targeted deletion to avoid deletion of any
+ * other objects.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId,
+ subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(InvalidOid);
+ replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
+ true;
+ }
+ }
+ }
+
update_tuple = true;
break;
}
@@ -2027,6 +2162,7 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
Form_pg_subscription form;
List *rstates;
bool must_use_password;
+ ObjectAddress object;
/*
* The launcher may concurrently start a new worker for this subscription.
@@ -2184,6 +2320,19 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
/* Clean up dependencies */
deleteSharedDependencyRecordsFor(SubscriptionRelationId, subid, 0);
+ /*
+ * Conflict log tables are recorded as internal dependencies of the
+ * subscription. We must drop the dependent objects before the
+ * subscription itself is removed. By using
+ * PERFORM_DELETION_SKIP_ORIGINAL, we ensure that only the conflict log
+ * table is reaped while the subscription remains for the final deletion
+ * step.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId, subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
/* Remove any associated relation synchronization states. */
RemoveSubscriptionRel(subid, InvalidOid);
@@ -3188,3 +3337,185 @@ defGetStreamingMode(DefElem *def)
def->defname)));
return LOGICALREP_STREAM_OFF; /* keep compiler quiet */
}
+
+/*
+ * Builds the TupleDesc for the conflict log table.
+ */
+static TupleDesc
+create_conflict_log_table_tupdesc(void)
+{
+ TupleDesc tupdesc;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_CONFLICT_ATTR_NUM);
+
+ for (int i = 0; i < MAX_CONFLICT_ATTR_NUM; i++)
+ {
+ Oid type_oid = ConflictLogSchema[i].atttypid;
+
+ /*
+ * Special handling for the JSON array type for proper
+ * TupleDescInitEntry call.
+ */
+ if (type_oid == JSONARRAYOID)
+ type_oid = get_array_type(JSONOID);
+
+ TupleDescInitEntry(tupdesc, i + 1,
+ ConflictLogSchema[i].attname,
+ type_oid,
+ -1, 0);
+ }
+
+ return BlessTupleDesc(tupdesc);
+}
+
+/*
+ * Create conflict log table.
+ *
+ * The subscription owner becomes the owner of this table and has all
+ * privileges on it.
+ */
+static Oid
+create_conflict_log_table(Oid subid, char *subname, Oid namespaceId,
+ char *conflictrel)
+{
+ TupleDesc tupdesc;
+ Oid relid;
+ char comment[256];
+ ObjectAddress myself;
+ ObjectAddress subaddr;
+
+ /*
+ * Conflict log tables must be permanent relations. Disallow creation in
+ * temporary namespaces to ensure the same.
+ */
+ if (isTempNamespace(namespaceId))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("could not generate conflict log table \"%s\"",
+ conflictrel),
+ errdetail("Conflict log tables cannot be created in a temporary namespace."),
+ errhint("Ensure your 'search_path' is set to permanent schema.")));
+
+ /* Report an error if the specified conflict log table already exists. */
+ if (OidIsValid(get_relname_relid(conflictrel, namespaceId)))
+ ereport(ERROR,
+ (errcode(ERRCODE_DUPLICATE_TABLE),
+ errmsg("could not generate conflict log table \"%s.%s\"",
+ get_namespace_name(namespaceId), conflictrel),
+ errdetail("A table with the internally generated name already exists."),
+ errhint("Drop the existing table or change your 'search_path' to use a different schema.")));
+
+ /* Build the tuple descriptor for the new table. */
+ tupdesc = create_conflict_log_table_tupdesc();
+
+ /* Create conflict log table. */
+ relid = heap_create_with_catalog(conflictrel,
+ namespaceId,
+ 0,
+ InvalidOid,
+ InvalidOid,
+ InvalidOid,
+ GetUserId(),
+ HEAP_TABLE_AM_OID,
+ tupdesc,
+ NIL,
+ RELKIND_RELATION,
+ RELPERSISTENCE_PERMANENT,
+ false,
+ false,
+ ONCOMMIT_NOOP,
+ (Datum) 0,
+ false,
+ false,
+ false,
+ InvalidOid,
+ NULL);
+
+ /* Add a comments for the conflict log table. */
+ snprintf(comment, sizeof(comment),
+ "Conflict log table for subscription \"%s\"", subname);
+ CreateComments(relid, RelationRelationId, 0, comment);
+
+ /*
+ * Establish an internal dependency between the conflict log table and
+ * the subscription. By using DEPENDENCY_INTERNAL, we ensure the table
+ * is automatically reaped when the subscription is dropped. This also
+ * prevents the table from being dropped independently unless the
+ * subscription itself is removed.
+ */
+ ObjectAddressSet(myself, RelationRelationId, relid);
+ ObjectAddressSet(subaddr, SubscriptionRelationId, subid);
+ recordDependencyOn(&myself, &subaddr, DEPENDENCY_INTERNAL);
+
+ /* Release tuple descriptor memory. */
+ FreeTupleDesc(tupdesc);
+
+ return relid;
+}
+
+/*
+ * Format the standardized internal conflict log table name for a subscription
+ *
+ * Use the OID to prevent collisions during rename operations.
+ */
+void
+GetConflictLogTableName(char *dest, Oid subid)
+{
+ snprintf(dest, NAMEDATALEN, "conflict_log_table_%u", subid);
+}
+
+/*
+ * GetLogDestination
+ *
+ * Convert string to enum by comparing against standardized labels.
+ */
+ConflictLogDest
+GetLogDestination(const char *dest)
+{
+ /* Empty string or NULL defaults to LOG. */
+ if (dest == NULL || dest[0] == '\0')
+ return CONFLICT_LOG_DEST_LOG;
+
+ for (int i = CONFLICT_LOG_DEST_LOG; i <= CONFLICT_LOG_DEST_ALL; i++)
+ {
+ if (pg_strcasecmp(dest, ConflictLogDestLabels[i]) == 0)
+ return (ConflictLogDest) i;
+ }
+
+ /* Unrecognized string. */
+ return CONFLICT_LOG_DEST_INVALID;
+}
+
+/*
+ * Check if the specified relation is used as a conflict log table by any
+ * subscription.
+ */
+bool
+IsConflictLogTable(Oid relid)
+{
+ Relation rel;
+ TableScanDesc scan;
+ HeapTuple tup;
+ bool is_clt = false;
+
+ rel = table_open(SubscriptionRelationId, AccessShareLock);
+ scan = table_beginscan_catalog(rel, 0, NULL);
+
+ while (HeapTupleIsValid(tup = heap_getnext(scan, ForwardScanDirection)))
+ {
+ Form_pg_subscription subform = (Form_pg_subscription) GETSTRUCT(tup);
+
+ /* Direct Oid comparison from catalog */
+ if (OidIsValid(subform->subconflictlogrelid) &&
+ subform->subconflictlogrelid == relid)
+ {
+ is_clt = true;
+ break;
+ }
+ }
+
+ table_endscan(scan);
+ table_close(rel, AccessShareLock);
+
+ return is_clt;
+}
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index 36f24502842..cc80f0f661c 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -6806,7 +6806,7 @@ describeSubscriptions(const char *pattern, bool verbose)
printQueryOpt myopt = pset.popt;
static const bool translate_columns[] = {false, false, false, false,
false, false, false, false, false, false, false, false, false, false,
- false, false, false, false};
+ false, false, false, false, false};
if (pset.sversion < 100000)
{
@@ -6900,15 +6900,22 @@ describeSubscriptions(const char *pattern, bool verbose)
appendPQExpBuffer(&buf,
", subskiplsn AS \"%s\"\n",
gettext_noop("Skip LSN"));
+
+ /* Conflict log destination is supported in v19 and higher */
+ if (pset.sversion >= 190000)
+ {
+ appendPQExpBuffer(&buf,
+ ", sublogdestination AS \"%s\"\n",
+ gettext_noop("Conflict log destination"));
+ }
}
/* Only display subscriptions in current database. */
- appendPQExpBufferStr(&buf,
- "FROM pg_catalog.pg_subscription\n"
- "WHERE subdbid = (SELECT oid\n"
- " FROM pg_catalog.pg_database\n"
- " WHERE datname = pg_catalog.current_database())");
-
+ appendPQExpBuffer(&buf,
+ "FROM pg_catalog.pg_subscription "
+ "WHERE subdbid = (SELECT oid\n"
+ " FROM pg_catalog.pg_database\n"
+ " WHERE datname = pg_catalog.current_database())");
if (!validateSQLNamePattern(&buf, pattern, true, false,
NULL, "subname", NULL,
NULL,
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index b1ff6f6cd94..fd279d98b1b 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -2344,8 +2344,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("(", "PUBLICATION");
/* ALTER SUBSCRIPTION <name> SET ( */
else if (Matches("ALTER", "SUBSCRIPTION", MatchAny, MatchAnyN, "SET", "("))
- COMPLETE_WITH("binary", "disable_on_error", "failover",
- "max_retention_duration", "origin",
+ COMPLETE_WITH("binary", "conflict_log_destination", "disable_on_error",
+ "failover", "max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
"synchronous_commit", "two_phase");
@@ -3828,8 +3828,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("WITH (");
/* Complete "CREATE SUBSCRIPTION <name> ... WITH ( <opt>" */
else if (Matches("CREATE", "SUBSCRIPTION", MatchAnyN, "WITH", "("))
- COMPLETE_WITH("binary", "connect", "copy_data", "create_slot",
- "disable_on_error", "enabled", "failover",
+ COMPLETE_WITH("binary", "conflict_log_destination", "connect", "copy_data",
+ "create_slot", "disable_on_error", "enabled", "failover",
"max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index 55cb9b1eefa..55f4bfa0419 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -90,6 +90,7 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
* exceeded max_retention_duration, when
* defined */
+ Oid subconflictlogrelid; /* Relid of the conflict log table. */
#ifdef CATALOG_VARLEN /* variable-length fields start here */
/* Connection string to the publisher */
text subconninfo BKI_FORCE_NOT_NULL;
@@ -103,6 +104,14 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
/* List of publications subscribed to */
text subpublications[1] BKI_FORCE_NOT_NULL;
+ /*
+ * Strategy for logging replication conflicts:
+ * log - server log only,
+ * table - internal table only,
+ * all - both log and table.
+ */
+ text sublogdestination;
+
/* Only publish data originating from the specified origin */
text suborigin BKI_DEFAULT(LOGICALREP_ORIGIN_ANY);
#endif
@@ -152,12 +161,14 @@ typedef struct Subscription
* and the retention duration has not
* exceeded max_retention_duration, when
* defined */
+ Oid conflictrelid; /* conflict log table Oid */
char *conninfo; /* Connection string to the publisher */
char *slotname; /* Name of the replication slot */
char *synccommit; /* Synchronous commit setting for worker */
List *publications; /* List of publication names to subscribe to */
char *origin; /* Only publish data originating from the
* specified origin */
+ char *logdestination; /* Conflict log destination */
} Subscription;
#ifdef EXPOSE_TO_CLIENT_CODE
diff --git a/src/include/commands/subscriptioncmds.h b/src/include/commands/subscriptioncmds.h
index fb4e26a51a4..255e1e241b8 100644
--- a/src/include/commands/subscriptioncmds.h
+++ b/src/include/commands/subscriptioncmds.h
@@ -17,6 +17,7 @@
#include "catalog/objectaddress.h"
#include "parser/parse_node.h"
+#include "replication/conflict.h"
extern ObjectAddress CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bool isTopLevel);
@@ -36,4 +37,8 @@ extern void CheckSubDeadTupleRetention(bool check_guc, bool sub_disabled,
bool retention_active,
bool max_retention_set);
+extern void GetConflictLogTableName(char *dest, Oid subid);
+extern ConflictLogDest GetLogDestination(const char *dest);
+extern bool IsConflictLogTable(Oid relid);
+
#endif /* SUBSCRIPTIONCMDS_H */
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index c8fbf9e51b8..70f8744b381 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -10,6 +10,7 @@
#define CONFLICT_H
#include "access/xlogdefs.h"
+#include "catalog/pg_type.h"
#include "nodes/pg_list.h"
#include "utils/timestamp.h"
@@ -79,6 +80,55 @@ typedef struct ConflictTupleInfo
* conflicting local row occurred */
} ConflictTupleInfo;
+/*
+ * Conflict log destination types.
+ *
+ * Internally we use these enum values for fast comparison, but we store
+ * the string equivalent in pg_subscription.sublogdestination.
+ */
+typedef enum ConflictLogDest
+{
+ CONFLICT_LOG_DEST_INVALID = 0,
+ CONFLICT_LOG_DEST_LOG, /* "log" (default) */
+ CONFLICT_LOG_DEST_TABLE, /* "table" */
+ CONFLICT_LOG_DEST_ALL /* "all" */
+} ConflictLogDest;
+
+/*
+ * Array mapping for converting internal enum to string.
+ */
+static const char *const ConflictLogDestLabels[] = {
+ [CONFLICT_LOG_DEST_LOG] = "log",
+ [CONFLICT_LOG_DEST_TABLE] = "table",
+ [CONFLICT_LOG_DEST_ALL] = "all"
+};
+
+/* Structure to hold metadata for one column of the conflict log table */
+typedef struct ConflictLogColumnDef
+{
+ const char *attname; /* Column name */
+ Oid atttypid; /* Data type OID */
+} ConflictLogColumnDef;
+
+/* The single source of truth for the conflict log table schema */
+static const ConflictLogColumnDef ConflictLogSchema[] =
+{
+ { .attname = "relid", .atttypid = OIDOID },
+ { .attname = "schemaname", .atttypid = TEXTOID },
+ { .attname = "relname", .atttypid = TEXTOID },
+ { .attname = "conflict_type", .atttypid = TEXTOID },
+ { .attname = "remote_xid", .atttypid = XIDOID },
+ { .attname = "remote_commit_lsn",.atttypid = LSNOID },
+ { .attname = "remote_commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "remote_origin", .atttypid = TEXTOID },
+ { .attname = "replica_identity", .atttypid = JSONOID },
+ { .attname = "remote_tuple", .atttypid = JSONOID },
+ { .attname = "local_conflicts", .atttypid = JSONARRAYOID }
+};
+
+/* Define the count using the array size */
+#define MAX_CONFLICT_ATTR_NUM (sizeof(ConflictLogSchema) / sizeof(ConflictLogSchema[0]))
+
extern bool GetTupleTransactionInfo(TupleTableSlot *localslot,
TransactionId *xmin,
RepOriginId *localorigin,
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index 327d1e7731f..4ab58d90925 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -116,18 +116,18 @@ CREATE SUBSCRIPTION regress_testsub4 CONNECTION 'dbname=regress_doesnotexist' PU
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub4 SET (origin = any);
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub3;
@@ -145,10 +145,10 @@ ALTER SUBSCRIPTION regress_testsub CONNECTION 'foobar';
ERROR: invalid connection string syntax: missing "=" after "foobar" in connection info string
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET PUBLICATION testpub2, testpub3 WITH (refresh = false);
@@ -157,10 +157,10 @@ ALTER SUBSCRIPTION regress_testsub SET (slot_name = 'newname');
ALTER SUBSCRIPTION regress_testsub SET (password_required = false);
ALTER SUBSCRIPTION regress_testsub SET (run_as_owner = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (password_required = true);
@@ -176,10 +176,10 @@ ERROR: unrecognized subscription parameter: "create_slot"
-- ok
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/12345');
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345 | log
(1 row)
-- ok - with lsn = NONE
@@ -188,10 +188,10 @@ ALTER SUBSCRIPTION regress_testsub SKIP (lsn = NONE);
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/0');
ERROR: invalid WAL location (LSN): 0/0
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
BEGIN;
@@ -223,10 +223,10 @@ ALTER SUBSCRIPTION regress_testsub_foo SET (synchronous_commit = foobar);
ERROR: invalid value for parameter "synchronous_commit": "foobar"
HINT: Available values: local, remote_write, remote_apply, on, off.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
----------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+---------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
-- rename back to keep the rest simple
@@ -255,19 +255,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (binary = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -279,27 +279,27 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = parallel);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- fail - publication already exists
@@ -314,10 +314,10 @@ ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refr
ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refresh = false);
ERROR: publication "testpub1" is already in subscription "regress_testsub"
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- fail - publication used more than once
@@ -332,10 +332,10 @@ ERROR: publication "testpub3" is not in subscription "regress_testsub"
-- ok - delete publications
ALTER SUBSCRIPTION regress_testsub DROP PUBLICATION testpub1, testpub2 WITH (refresh = false);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -371,19 +371,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- we can alter streaming when two_phase enabled
ALTER SUBSCRIPTION regress_testsub SET (streaming = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -393,10 +393,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -409,18 +409,18 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (disable_on_error = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -433,10 +433,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -450,19 +450,19 @@ NOTICE: max_retention_duration is ineffective when retain_dead_tuples is disabl
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- ok
ALTER SUBSCRIPTION regress_testsub SET (max_retention_duration = 0);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -517,7 +517,159 @@ COMMIT;
-- ok, owning it is enough for this stuff
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG DESTINATION TESTS
+--
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+-- fail - unrecognized format value
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
+ERROR: unrecognized conflict_log_destination value: "invalid"
+HINT: Valid values are "log", "table", and "all".
+-- verify sublogdestination is 'log' and relid is 0 (InvalidOid) for default case
+CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+SELECT subname, sublogdestination, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
+ subname | sublogdestination | subconflictlogrelid
+------------------------------+-------------------+---------------------
+ regress_conflict_log_default | log | 0
+(1 row)
+
+-- verify empty string defaults to 'log'
+CREATE SUBSCRIPTION regress_conflict_empty_str CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = '');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+SELECT subname, sublogdestination, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
+ subname | sublogdestination | subconflictlogrelid
+----------------------------+-------------------+---------------------
+ regress_conflict_empty_str | log | 0
+(1 row)
+
+-- this should generate an internal table named conflict_log_table_$subid$
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- check metadata in pg_subscription: destination should be 'table' and relid valid
+SELECT subname, sublogdestination, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname | sublogdestination | has_relid
+------------------------+-------------------+-----------
+ regress_conflict_test1 | table | t
+(1 row)
+
+-- verify the physical table exists and its OID matches subconflictlogrelid
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND c.oid = s.subconflictlogrelid;
+ count
+-------
+ 1
+(1 row)
+
+-- check if the internal table has the correct schema (11 columns)
+SELECT count(*)
+FROM pg_attribute a
+JOIN pg_class c ON a.attrelid = c.oid
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0;
+ count
+-------
+ 11
+(1 row)
+
+-- ALTER: State transitions
+-- transition from 'log' to 'all'
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
+-- verify metadata after ALTER (destination should be 'all')
+SELECT subname, sublogdestination, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | sublogdestination | has_relid
+------------------------+-------------------+-----------
+ regress_conflict_test2 | all | t
+(1 row)
+
+-- transition from 'all' to 'table' (should NOT drop the table, only change destination string)
+SELECT subconflictlogrelid AS old_relid FROM pg_subscription WHERE subname = 'regress_conflict_test2' \gset
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'table');
+SELECT sublogdestination, subconflictlogrelid = :old_relid AS relid_unchanged
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ sublogdestination | relid_unchanged
+-------------------+-----------------
+ table | t
+(1 row)
+
+-- transition from 'table' to 'log' (should drop the table and clear relid)
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log');
+SELECT sublogdestination, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ sublogdestination | subconflictlogrelid
+-------------------+---------------------
+ log | 0
+(1 row)
+
+-- verify the physical table is gone
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test2';
+ count
+-------
+ 0
+(1 row)
+
+-- ensure drop table not allowed and DROP SUBSCRIPTION reaps the table
+-- re-enable table logging for verification
+ALTER SUBSCRIPTION regress_conflict_test1 SET (conflict_log_destination = 'table');
+-- fail - drop table not allowed due to internal dependency
+-- use DO block to hide OID in error message
+DO $$
+BEGIN
+ EXECUTE 'DROP TABLE ' || (SELECT 'conflict_log_table_' || oid FROM pg_subscription WHERE subname = 'regress_conflict_test1');
+EXCEPTION WHEN dependent_objects_still_exist THEN
+ RAISE NOTICE 'captured expected error: dependent_objects_still_exist';
+END $$;
+NOTICE: captured expected error: dependent_objects_still_exist
+-- PUBLICATION: Verify internal tables are not publishable
+-- pg_relation_is_publishable should return false for internal conflict log tables
+SELECT pg_relation_is_publishable(subconflictlogrelid)
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ pg_relation_is_publishable
+----------------------------
+ f
+(1 row)
+
+-- CLEANUP: Proper drop reaps the table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+-- Verify the table OID for reap check
+SELECT 'conflict_log_table_' || oid AS internal_tablename FROM pg_subscription WHERE subname = 'regress_conflict_test1' \gset
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the internal table was reaped via dependency
+SELECT to_regclass(:'internal_tablename');
+ to_regclass
+-------------
+
+(1 row)
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_log_default DISABLE;
+ALTER SUBSCRIPTION regress_conflict_log_default SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_log_default;
+ALTER SUBSCRIPTION regress_conflict_empty_str DISABLE;
+ALTER SUBSCRIPTION regress_conflict_empty_str SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_empty_str;
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
RESET SESSION AUTHORIZATION;
+DROP SCHEMA clt;
+ERROR: schema "clt" does not exist
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
DROP ROLE regress_subscription_user3;
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index ef0c298d2df..3359ff8be5c 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -365,7 +365,116 @@ COMMIT;
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG DESTINATION TESTS
+--
+
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+
+-- fail - unrecognized format value
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
+
+-- verify sublogdestination is 'log' and relid is 0 (InvalidOid) for default case
+CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+SELECT subname, sublogdestination, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
+
+-- verify empty string defaults to 'log'
+CREATE SUBSCRIPTION regress_conflict_empty_str CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = '');
+SELECT subname, sublogdestination, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
+
+-- this should generate an internal table named conflict_log_table_$subid$
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+
+-- check metadata in pg_subscription: destination should be 'table' and relid valid
+SELECT subname, sublogdestination, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- verify the physical table exists and its OID matches subconflictlogrelid
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND c.oid = s.subconflictlogrelid;
+
+-- check if the internal table has the correct schema (11 columns)
+SELECT count(*)
+FROM pg_attribute a
+JOIN pg_class c ON a.attrelid = c.oid
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0;
+
+-- ALTER: State transitions
+-- transition from 'log' to 'all'
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
+
+-- verify metadata after ALTER (destination should be 'all')
+SELECT subname, sublogdestination, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'all' to 'table' (should NOT drop the table, only change destination string)
+SELECT subconflictlogrelid AS old_relid FROM pg_subscription WHERE subname = 'regress_conflict_test2' \gset
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'table');
+SELECT sublogdestination, subconflictlogrelid = :old_relid AS relid_unchanged
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'table' to 'log' (should drop the table and clear relid)
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log');
+SELECT sublogdestination, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- verify the physical table is gone
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test2';
+
+-- ensure drop table not allowed and DROP SUBSCRIPTION reaps the table
+-- re-enable table logging for verification
+ALTER SUBSCRIPTION regress_conflict_test1 SET (conflict_log_destination = 'table');
+
+-- fail - drop table not allowed due to internal dependency
+-- use DO block to hide OID in error message
+DO $$
+BEGIN
+ EXECUTE 'DROP TABLE ' || (SELECT 'conflict_log_table_' || oid FROM pg_subscription WHERE subname = 'regress_conflict_test1');
+EXCEPTION WHEN dependent_objects_still_exist THEN
+ RAISE NOTICE 'captured expected error: dependent_objects_still_exist';
+END $$;
+
+-- PUBLICATION: Verify internal tables are not publishable
+-- pg_relation_is_publishable should return false for internal conflict log tables
+SELECT pg_relation_is_publishable(subconflictlogrelid)
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- CLEANUP: Proper drop reaps the table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+
+-- Verify the table OID for reap check
+SELECT 'conflict_log_table_' || oid AS internal_tablename FROM pg_subscription WHERE subname = 'regress_conflict_test1' \gset
+
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the internal table was reaped via dependency
+SELECT to_regclass(:'internal_tablename');
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_log_default DISABLE;
+ALTER SUBSCRIPTION regress_conflict_log_default SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_log_default;
+
+ALTER SUBSCRIPTION regress_conflict_empty_str DISABLE;
+ALTER SUBSCRIPTION regress_conflict_empty_str SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_empty_str;
+
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+
RESET SESSION AUTHORIZATION;
+DROP SCHEMA clt;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
DROP ROLE regress_subscription_user3;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 04845d5e680..21826be5bd7 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -500,6 +500,7 @@ ConditionVariableMinimallyPadded
ConditionalStack
ConfigData
ConfigVariable
+ConflictLogColumnDef
ConflictTupleInfo
ConflictType
ConnCacheEntry
--
2.49.0
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-22 10:34 shveta malik <shveta.malik@gmail.com>
parent: vignesh C <vignesh21@gmail.com>
0 siblings, 0 replies; 912+ messages in thread
From: shveta malik @ 2025-12-22 10:34 UTC (permalink / raw)
To: vignesh C <vignesh21@gmail.com>; +Cc: Dilip Kumar <dilipbalaut@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Mon, Dec 22, 2025 at 3:55 PM vignesh C <vignesh21@gmail.com> wrote:
>
>
> Few comments:
> 1) when a conflict_log_destination is specified as log:
> create subscription sub1 connection 'dbname=postgres host=localhost
> port=5432' publication pub1 with ( conflict_log_destination='log');
> postgres=# select subname, subconflictlogrelid,sublogdestination from
> pg_subscription where subname = 'sub4';
> subname | subconflictlogrelid | sublogdestination
> ---------+---------------------+-------------------
> sub4 | 0 | log
> (1 row)
>
> Currently it displays as 0, instead we can show as NULL in this case
I also thought about it while reviewing, but I feel 0 makes more sense
as it is 'relid'. This is how it is shown currently in other tables.
See 'reltoastrelid':
postgres=# select relname, reltoastrelid from pg_class where relname='tab1';
relname | reltoastrelid
---------+---------------
tab1 | 0
(1 row)
>
> 3) Can we include pg_ in the conflict table to indicate it is an
> internally created table:
> +/*
> + * Format the standardized internal conflict log table name for a subscription
> + *
> + * Use the OID to prevent collisions during rename operations.
> + */
> +void
> +GetConflictLogTableName(char *dest, Oid subid)
> +{
> + snprintf(dest, NAMEDATALEN, "conflict_log_table_%u", subid);
> +}
>
There is already a discussion about it in [1]
[1]: https://www.postgresql.org/message-id/CAA4eK1KE%3DtNHcN3Qp0FZVwDnt4rF2zwHy8NgAdG3oPqixdzOsA%40mail.g...
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-22 15:41 Dilip Kumar <dilipbalaut@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-12-22 15:41 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Mon, Dec 22, 2025 at 3:09 PM shveta malik <shveta.malik@gmail.com> wrote:
I think this needs more thought, others can be fixed.
> 2)
> postgres=# drop schema shveta cascade;
> NOTICE: drop cascades to subscription sub1
> ERROR: global objects cannot be deleted by doDeletion
>
> Is this expected? Is the user supposed to see this error?
>
See below code, so this says if the object being dropped is the
outermost object (i.e. if we are dropping the table directly) then it
will disallow dropping the object on which it has INTERNAL DEPENDENCY,
OTOH if the object is being dropped via recursive drop (i.e. the table
is being dropped while dropping the schema) then object on which it
has INTERNAL dependency will also be added to the deletion list and
later will be dropped via doDeletion and later we are getting error as
subscription is a global object. I thought maybe we can handle an
additional case that the INTERNAL DEPENDENCY, is on subscription the
disallow dropping it irrespective of whether it is being called
directly or via recursive drop but then it will give an issue even
when we are trying to drop table during subscription drop, we can make
handle this case as well via 'flags' passed in findDependentObjects()
but need more investigation.
Seeing this complexity makes me think more on is it really worth it to
maintain this dependency? Because during subscription drop we anyway
have to call performDeletion externally because this dependency is
local so we are just disallowing the conflict table drop, however the
ALTER table is allowed so what we are really protecting by protecting
the table drop, I think it can be just documented that if user try to
drop the table then conflict will not be inserted anymore?
findDependentObjects()
{
...
switch (foundDep->deptype)
{
....
case DEPENDENCY_INTERNAL:
* 1. At the outermost recursion level, we must disallow the
* DROP. However, if the owning object is listed in
* pendingObjects, just release the caller's lock and return;
* we'll eventually complete the DROP when we reach that entry
* in the pending list.
}
}
[1]
postgres[1333899]=# select * from pg_depend where objid > 16410;
classid | objid | objsubid | refclassid | refobjid | refobjsubid | deptype
---------+-------+----------+------------+----------+-------------+---------
1259 | 16420 | 0 | 2615 | 16410 | 0 | n
1259 | 16420 | 0 | 6100 | 16419 | 0 | i
(4 rows)
16420 -> conflict_log_table_16419
16419 -> subscription
16410 -> schema s1
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-23 05:25 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 2 replies; 912+ messages in thread
From: shveta malik @ 2025-12-23 05:25 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Mon, Dec 22, 2025 at 9:11 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Mon, Dec 22, 2025 at 3:09 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> I think this needs more thought, others can be fixed.
>
> > 2)
> > postgres=# drop schema shveta cascade;
> > NOTICE: drop cascades to subscription sub1
> > ERROR: global objects cannot be deleted by doDeletion
> >
> > Is this expected? Is the user supposed to see this error?
> >
> See below code, so this says if the object being dropped is the
> outermost object (i.e. if we are dropping the table directly) then it
> will disallow dropping the object on which it has INTERNAL DEPENDENCY,
> OTOH if the object is being dropped via recursive drop (i.e. the table
> is being dropped while dropping the schema) then object on which it
> has INTERNAL dependency will also be added to the deletion list and
> later will be dropped via doDeletion and later we are getting error as
> subscription is a global object. I thought maybe we can handle an
> additional case that the INTERNAL DEPENDENCY, is on subscription the
> disallow dropping it irrespective of whether it is being called
> directly or via recursive drop but then it will give an issue even
> when we are trying to drop table during subscription drop, we can make
> handle this case as well via 'flags' passed in findDependentObjects()
> but need more investigation.
>
> Seeing this complexity makes me think more on is it really worth it to
> maintain this dependency? Because during subscription drop we anyway
> have to call performDeletion externally because this dependency is
> local so we are just disallowing the conflict table drop, however the
> ALTER table is allowed so what we are really protecting by protecting
> the table drop, I think it can be just documented that if user try to
> drop the table then conflict will not be inserted anymore?
>
> findDependentObjects()
> {
> ...
> switch (foundDep->deptype)
> {
> ....
> case DEPENDENCY_INTERNAL:
> * 1. At the outermost recursion level, we must disallow the
> * DROP. However, if the owning object is listed in
> * pendingObjects, just release the caller's lock and return;
> * we'll eventually complete the DROP when we reach that entry
> * in the pending list.
> }
> }
>
> [1]
> postgres[1333899]=# select * from pg_depend where objid > 16410;
> classid | objid | objsubid | refclassid | refobjid | refobjsubid | deptype
> ---------+-------+----------+------------+----------+-------------+---------
> 1259 | 16420 | 0 | 2615 | 16410 | 0 | n
> 1259 | 16420 | 0 | 6100 | 16419 | 0 | i
> (4 rows)
>
> 16420 -> conflict_log_table_16419
> 16419 -> subscription
> 16410 -> schema s1
>
One approach could be to use something similar to
PERFORM_DELETION_SKIP_EXTENSIONS in our case, but only for recursive
drops. The effect would be that 'DROP SCHEMA ... CASCADE' would
proceed without error, i.e., it would drop the tables as well without
including the subscription in the dependency list. But if we try to
drop a table directly (e.g., DROP TABLE CLT), it will still result in:
ERROR: cannot drop table because subscription sub1 requires it
The behavior will resemble a dependency somewhere between type 'n' and
type 'i'. That said, I’m not sure if this is worth the effort, even
though it prevents direct drop of table, it still does not prevent
table from being dropped as part of a schema drop.
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-23 06:11 Dilip Kumar <dilipbalaut@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
1 sibling, 0 replies; 912+ messages in thread
From: Dilip Kumar @ 2025-12-23 06:11 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, Dec 23, 2025 at 10:55 AM shveta malik <shveta.malik@gmail.com> wrote:
>
> One approach could be to use something similar to
> PERFORM_DELETION_SKIP_EXTENSIONS in our case, but only for recursive
> drops. The effect would be that 'DROP SCHEMA ... CASCADE' would
> proceed without error, i.e., it would drop the tables as well without
> including the subscription in the dependency list. But if we try to
> drop a table directly (e.g., DROP TABLE CLT), it will still result in:
> ERROR: cannot drop table because subscription sub1 requires it
>
> The behavior will resemble a dependency somewhere between type 'n' and
> type 'i'. That said, I’m not sure if this is worth the effort, even
> though it prevents direct drop of table, it still does not prevent
> table from being dropped as part of a schema drop.
Yeah but that would be inconsistent behavior. Anyway here is what I
got with what I was proposing yesterday.[1], so basically drop schema
and drop table are giving the same behavior as expected and drop
subscription is internally dropping the table as we would want.
Although this need more thought to see what else it might break.
postgres[1553010]=# CREATE SCHEMA s1;
postgres[1553010]=# SET search_path TO s1;
postgres[1553010]=# CREATE SUBSCRIPTION sub1 CONNECTION
'dbname=postgres port=5432' PUBLICATION pub WITH
(conflict_log_destination = table);
postgres[1553010]=# \d
List of relations
Schema | Name | Type | Owner
--------+--------------------------+-------+-------------
s1 | conflict_log_table_16428 | table | dilipkumarb
(1 row)
postgres[1553010]=# DROP SCHEMA s1;
ERROR: 2BP01: cannot drop table conflict_log_table_16428 because
subscription sub1 requires it
HINT: You can drop subscription sub1 instead.
LOCATION: findDependentObjects, dependency.c:843
postgres[1553010]=# DROP TABLE conflict_log_table_16428 ;
ERROR: 2BP01: cannot drop table conflict_log_table_16428 because
subscription sub1 requires it
HINT: You can drop subscription sub1 instead.
LOCATION: findDependentObjects, dependency.c:843
postgres[1553010]=# DROP SUBSCRIPTION sub1;
NOTICE: 00000: dropped replication slot
"pg_16428_sync_16385_7586930395971240479" on publisher
LOCATION: ReplicationSlotDropAtPubNode, subscriptioncmds.c:2469
NOTICE: 00000: dropped replication slot "sub1" on publisher
LOCATION: ReplicationSlotDropAtPubNode, subscriptioncmds.c:2469
DROP SUBSCRIPTION
[1]
diff --git a/src/backend/catalog/dependency.c b/src/backend/catalog/dependency.c
index 7489bbd5fb3..14184d076d3 100644
--- a/src/backend/catalog/dependency.c
+++ b/src/backend/catalog/dependency.c
@@ -662,6 +662,11 @@ findDependentObjects(const ObjectAddress *object,
* However, no inconsistency can
result: since we're at outer
* level, there is no object depending
on this one.
*/
+ if
(IsSharedRelation(otherObject.classId) && !(flags &
PERFORM_DELETION_INTERNAL))
+ {
+ owningObject = otherObject;
+ break;
+ }
if (stack == NULL)
{
if (pendingObjects &&
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-23 06:15 vignesh C <vignesh21@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
5 siblings, 0 replies; 912+ messages in thread
From: vignesh C @ 2025-12-23 06:15 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Sat, 20 Dec 2025 at 16:51, Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Sat, Dec 20, 2025 at 3:17 PM vignesh C <vignesh21@gmail.com> wrote:
> >
> > On Tue, 16 Dec 2025 at 09:54, vignesh C <vignesh21@gmail.com> wrote:
> > >
> > > On Sun, 14 Dec 2025 at 21:17, Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > >
> > > > On Sun, Dec 14, 2025 at 3:51 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > > >
> > > > > On Fri, Dec 12, 2025 at 3:04 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > > > > >
> > > > > > On Thu, Dec 11, 2025 at 7:49 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > > > > >
> > > > > > > I was considering the interdependence between the subscription and the
> > > > > > > conflict log table (CLT). IMHO, it would be logical to establish the
> > > > > > > subscription as dependent on the CLT. This way, if someone attempts to
> > > > > > > drop the CLT, the system would recognize the dependency of the
> > > > > > > subscription and prevent the drop unless the subscription is removed
> > > > > > > first or the CASCADE option is used.
> > > > > > >
> > > > > > > However, while investigating this, I encountered an error [1] stating
> > > > > > > that global objects are not supported in this context. This indicates
> > > > > > > that global objects cannot be made dependent on local objects.
> > > > > > >
> > > > > >
> > > > > > What we need here is an equivalent of DEPENDENCY_INTERNAL for database
> > > > > > objects. For example, consider following case:
> > > > > > postgres=# create table t1(c1 int primary key);
> > > > > > CREATE TABLE
> > > > > > postgres=# \d+ t1
> > > > > > Table "public.t1"
> > > > > > Column | Type | Collation | Nullable | Default | Storage |
> > > > > > Compression | Stats target | Description
> > > > > > --------+---------+-----------+----------+---------+---------+-------------+--------------+-------------
> > > > > > c1 | integer | | not null | | plain |
> > > > > > | |
> > > > > > Indexes:
> > > > > > "t1_pkey" PRIMARY KEY, btree (c1)
> > > > > > Publications:
> > > > > > "pub1"
> > > > > > Not-null constraints:
> > > > > > "t1_c1_not_null" NOT NULL "c1"
> > > > > > Access method: heap
> > > > > > postgres=# drop index t1_pkey;
> > > > > > ERROR: cannot drop index t1_pkey because constraint t1_pkey on table
> > > > > > t1 requires it
> > > > > > HINT: You can drop constraint t1_pkey on table t1 instead.
> > > > > >
> > > > > > Here, the PK index is created as part for CREATE TABLE operation and
> > > > > > pk_index is not allowed to be dropped independently.
> > > > > >
> > > > > > > Although making an object dependent on global/shared objects is
> > > > > > > possible for certain types of shared objects [2], this is not our main
> > > > > > > objective.
> > > > > > >
> > > > > >
> > > > > > As per my understanding from the above example, we need something like
> > > > > > that only for shared object subscription and (internally created)
> > > > > > table.
> > > > >
> > > > > Yeah that seems to be exactly what we want, so I tried doing that by
> > > > > recording DEPENDENCY_INTERNAL dependency of CLT on subscription[1] and
> > > > > it is behaving as we want[2]. And while dropping the subscription or
> > > > > altering CLT we can delete internal dependency so that CLT get dropped
> > > > > automatically[3]
> > > > >
> > > > > I will send an updated patch after testing a few more scenarios and
> > > > > fixing other pending issues.
> > > > >
> > > > > [1]
> > > > > + ObjectAddressSet(myself, RelationRelationId, relid);
> > > > > + ObjectAddressSet(subaddr, SubscriptionRelationId, subid);
> > > > > + recordDependencyOn(&myself, &subaddr, DEPENDENCY_INTERNAL);
> > > > >
> > > > >
> > > > > [2]
> > > > > postgres[670778]=# DROP TABLE myschema.conflict_log_history2;
> > > > > ERROR: 2BP01: cannot drop table myschema.conflict_log_history2
> > > > > because subscription sub requires it
> > > > > HINT: You can drop subscription sub instead.
> > > > > LOCATION: findDependentObjects, dependency.c:788
> > > > > postgres[670778]=#
> > > > >
> > > > > [3]
> > > > > ObjectAddressSet(object, SubscriptionRelationId, subid);
> > > > > performDeletion(&object, DROP_CASCADE
> > > > > PERFORM_DELETION_INTERNAL |
> > > > > PERFORM_DELETION_SKIP_ORIGINAL);
> > > > >
> > > > >
> > > >
> > > > Here is the patch which implements the dependency and fixes other
> > > > comments from Shveta.
> > >
> > > Thanks for the changes, the new implementation based on dependency
> > > creates a cycle while dumping:
> > > ./pg_dump -d postgres -f dump1.txt -p 5433
> > > pg_dump: warning: could not resolve dependency loop among these items:
> > > pg_dump: detail: TABLE conflict (ID 225 OID 16397)
> > > pg_dump: detail: SUBSCRIPTION (ID 3484 OID 16396)
> > > pg_dump: detail: POST-DATA BOUNDARY (ID 3491)
> > > pg_dump: detail: TABLE DATA t1 (ID 3485 OID 16384)
> > > pg_dump: detail: PRE-DATA BOUNDARY (ID 3490)
> > >
> > > This can be seen with a simple subscription with conflict_log_table.
> > > This was working fine with the v11 version patch.
> >
> > The attached v13 patch includes the fix for this issue. In addition,
> > it now raises an error when attempting to configure a conflict log
> > table that belongs to a temporary schema or is not a permanent
> > (persistent) relation.
>
> I have updated the patch and here are changes done
> 1. Splitted into 2 patches, 0001- for catalog related changes
> 0002-inserting conflict into the conflict table, Vignesh need to
> rebase the dump and upgrade related patch on this latest changes
Here is a rebased version of the dump/upgrade patch based on the v15
version posted at [1].
After replacing conflict_log_table with conflict_log_destination, we
don't specify a fully qualified table name directly. Instead, the
conflict log behavior is controlled via conflict_log_destination
(table, log, or all). Since pg_dump resets search_path, it must
explicitly set the schema in which the conflict log table should be
created or reused. To handle this, pg_dump temporarily sets and then
restores search_path around the ALTER SUBSCRIPTION ... SET
(conflict_log_destination ...) command, ensuring the conflict log
table is resolved in the intended schema.
Additionally, in non-upgrade dump/restore scenarios, the conflict log
table is not dumped as in non-upgrade mode it does not make sense to
link with the older conflict log table.
v15-0001 to v15-0004 is the same as the patches posted at [1].
dump/upgrade changes are present in v15-0005 patch.
[1] - https://www.postgresql.org/message-id/CAFiTN-uKn7mix8BkOOmJQ2cF5yKdfQUg2mX_w9vEC4787VZ_xQ%40mail.gma...
Regards.
Vignesh
Attachments:
[text/x-patch] v15-0001-Add-configurable-conflict-log-table-for-Logical-.patch (97.9K, ../../CALDaNm1zR1L2oq-LqYEcc8-wTZYjfJsiaTC_jQ8pGwbm0fv+3Q@mail.gmail.com/2-v15-0001-Add-configurable-conflict-log-table-for-Logical-.patch)
download | inline diff:
From 828f133d7a4d7b672f210fb70054eb7e43e65e59 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 17 Dec 2025 11:53:47 +0530
Subject: [PATCH v15 1/5] Add configurable conflict log table for Logical
Replication
This patch adds a feature to provide a structured, queryable record of all
logical replication conflicts. The current approach of logging conflicts as
plain text in the server logs makes it difficult to query, analyze, and
use for external monitoring and automation.
This patch addresses these limitations by introducing a configurable
conflict_log_destination=(log/table/all) option in the CREATE SUBSCRIPTION
command.
If user choose to log into the table the table will automatically created while
creating the subscription with internal name i.e. conflict_log_table_$subid$. The
table will be created in the current search path and table would be automatically
dropped while dropping the subscription.
The conflict details, including the original and remote tuples, are stored in JSON
columns, providing a flexible format to accommodate different table schemas.
The log table captures essential attributes such as local and remote transaction IDs,
LSNs, commit timestamps, and conflict type, providing a complete record for post-mortem
analysis.
This feature will make logical replication conflicts easier to monitor and manage,
significantly improving the overall resilience and operability of replication setups.
The conflict log tables will not be included in a publication, even if the publication
is configured to include ALL TABLES or ALL TABLES IN SCHEMA.
---
src/backend/catalog/pg_publication.c | 26 +-
src/backend/catalog/pg_subscription.c | 7 +
src/backend/commands/subscriptioncmds.c | 337 ++++++++++++++++++++-
src/bin/psql/describe.c | 21 +-
src/bin/psql/tab-complete.in.c | 8 +-
src/include/catalog/pg_subscription.h | 11 +
src/include/commands/subscriptioncmds.h | 5 +
src/include/replication/conflict.h | 50 +++
src/test/regress/expected/subscription.out | 328 ++++++++++++++------
src/test/regress/sql/subscription.sql | 109 +++++++
src/tools/pgindent/typedefs.list | 1 +
11 files changed, 799 insertions(+), 104 deletions(-)
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index 7aa3f179924..9f84e02b7ef 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -31,6 +31,7 @@
#include "catalog/pg_publication_rel.h"
#include "catalog/pg_type.h"
#include "commands/publicationcmds.h"
+#include "commands/subscriptioncmds.h"
#include "funcapi.h"
#include "utils/array.h"
#include "utils/builtins.h"
@@ -85,6 +86,15 @@ check_publication_add_relation(Relation targetrel)
errmsg("cannot add relation \"%s\" to publication",
RelationGetRelationName(targetrel)),
errdetail("This operation is not supported for unlogged tables.")));
+
+ /* Can't be conflict log table */
+ if (IsConflictLogTable(RelationGetRelid(targetrel)))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot add relation \"%s.%s\" to publication",
+ get_namespace_name(RelationGetNamespace(targetrel)),
+ RelationGetRelationName(targetrel)),
+ errdetail("This operation is not supported for conflict log tables.")));
}
/*
@@ -145,6 +155,13 @@ is_publishable_class(Oid relid, Form_pg_class reltuple)
/*
* Another variant of is_publishable_class(), taking a Relation.
+ *
+ * Note: Conflict log tables are not publishable. However, we intentionally
+ * skip this check here because this function is called for every change and
+ * performing this check during every change publication is costly. To ensure
+ * unpublishable entries are ignored without incurring performance overhead,
+ * tuples inserted into the conflict log table uses the HEAP_INSERT_NO_LOGICAL
+ * flag. This allows the decoding layer to bypass these entries automatically.
*/
bool
is_publishable_relation(Relation rel)
@@ -169,7 +186,10 @@ pg_relation_is_publishable(PG_FUNCTION_ARGS)
tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
if (!HeapTupleIsValid(tuple))
PG_RETURN_NULL();
- result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple));
+
+ /* Subscription conflict log tables are not published */
+ result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple)) &&
+ !IsConflictLogTable(relid);
ReleaseSysCache(tuple);
PG_RETURN_BOOL(result);
}
@@ -890,7 +910,9 @@ GetAllPublicationRelations(char relkind, bool pubviaroot)
Form_pg_class relForm = (Form_pg_class) GETSTRUCT(tuple);
Oid relid = relForm->oid;
+ /* Subscription conflict log tables are not published */
if (is_publishable_class(relid, relForm) &&
+ !IsConflictLogTable(relid) &&
!(relForm->relispartition && pubviaroot))
result = lappend_oid(result, relid);
}
@@ -1018,7 +1040,7 @@ GetSchemaPublicationRelations(Oid schemaid, PublicationPartOpt pub_partopt)
Oid relid = relForm->oid;
char relkind;
- if (!is_publishable_class(relid, relForm))
+ if (!is_publishable_class(relid, relForm) || IsConflictLogTable(relid))
continue;
relkind = get_rel_relkind(relid);
diff --git a/src/backend/catalog/pg_subscription.c b/src/backend/catalog/pg_subscription.c
index ad6fbd77ffd..27a9aee1c56 100644
--- a/src/backend/catalog/pg_subscription.c
+++ b/src/backend/catalog/pg_subscription.c
@@ -106,6 +106,7 @@ GetSubscription(Oid subid, bool missing_ok)
sub->retaindeadtuples = subform->subretaindeadtuples;
sub->maxretention = subform->submaxretention;
sub->retentionactive = subform->subretentionactive;
+ sub->conflictrelid = subform->subconflictlogrelid;
/* Get conninfo */
datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
@@ -141,6 +142,12 @@ GetSubscription(Oid subid, bool missing_ok)
Anum_pg_subscription_suborigin);
sub->origin = TextDatumGetCString(datum);
+ /* Get conflict log destination */
+ datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
+ tup,
+ Anum_pg_subscription_sublogdestination);
+ sub->logdestination = TextDatumGetCString(datum);
+
/* Is the subscription owner a superuser? */
sub->ownersuperuser = superuser_arg(sub->owner);
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index abbcaff0838..05dcb5e3fe4 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -15,25 +15,30 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/htup_details.h"
#include "access/table.h"
#include "access/twophase.h"
#include "access/xact.h"
#include "catalog/catalog.h"
#include "catalog/dependency.h"
+#include "catalog/heap.h"
#include "catalog/indexing.h"
#include "catalog/namespace.h"
#include "catalog/objectaccess.h"
#include "catalog/objectaddress.h"
+#include "catalog/pg_am_d.h"
#include "catalog/pg_authid_d.h"
#include "catalog/pg_database_d.h"
#include "catalog/pg_subscription.h"
#include "catalog/pg_subscription_rel.h"
#include "catalog/pg_type.h"
+#include "commands/comment.h"
#include "commands/defrem.h"
#include "commands/event_trigger.h"
#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "miscadmin.h"
#include "nodes/makefuncs.h"
#include "pgstat.h"
@@ -51,6 +56,7 @@
#include "utils/lsyscache.h"
#include "utils/memutils.h"
#include "utils/pg_lsn.h"
+#include "utils/regproc.h"
#include "utils/syscache.h"
/*
@@ -75,6 +81,7 @@
#define SUBOPT_MAX_RETENTION_DURATION 0x00008000
#define SUBOPT_LSN 0x00010000
#define SUBOPT_ORIGIN 0x00020000
+#define SUBOPT_CONFLICT_LOG_DESTINATION 0x00040000
/* check if the 'val' has 'bits' set */
#define IsSet(val, bits) (((val) & (bits)) == (bits))
@@ -103,6 +110,7 @@ typedef struct SubOpts
bool retaindeadtuples;
int32 maxretention;
char *origin;
+ ConflictLogDest logdest;
XLogRecPtr lsn;
} SubOpts;
@@ -135,7 +143,8 @@ static List *merge_publications(List *oldpublist, List *newpublist, bool addpub,
static void ReportSlotConnectionError(List *rstates, Oid subid, char *slotname, char *err);
static void CheckAlterSubOption(Subscription *sub, const char *option,
bool slot_needs_update, bool isTopLevel);
-
+static Oid create_conflict_log_table(Oid subid, char *subname, Oid namespaceId,
+ char *conflictrel);
/*
* Common option parsing function for CREATE and ALTER SUBSCRIPTION commands.
@@ -191,6 +200,8 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->maxretention = 0;
if (IsSet(supported_opts, SUBOPT_ORIGIN))
opts->origin = pstrdup(LOGICALREP_ORIGIN_ANY);
+ if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DESTINATION))
+ opts->logdest = CONFLICT_LOG_DEST_LOG;
/* Parse options */
foreach(lc, stmt_options)
@@ -402,6 +413,28 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->specified_opts |= SUBOPT_LSN;
opts->lsn = lsn;
}
+ else if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DESTINATION) &&
+ strcmp(defel->defname, "conflict_log_destination") == 0)
+ {
+ char *val;
+ ConflictLogDest dest;
+
+ if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_LOG_DESTINATION))
+ errorConflictingDefElem(defel, pstate);
+
+ val = defGetString(defel);
+
+ dest = GetLogDestination(val);
+
+ if (dest == CONFLICT_LOG_DEST_INVALID)
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("unrecognized conflict_log_destination value: \"%s\"", val),
+ errhint("Valid values are \"log\", \"table\", and \"all\".")));
+
+ opts->logdest = dest;
+ opts->specified_opts |= SUBOPT_CONFLICT_LOG_DESTINATION;
+ }
else
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
@@ -612,7 +645,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
SUBOPT_DISABLE_ON_ERR | SUBOPT_PASSWORD_REQUIRED |
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
- SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN);
+ SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_DESTINATION);
parse_subscription_options(pstate, stmt->options, supported_opts, &opts);
/*
@@ -747,6 +781,40 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
values[Anum_pg_subscription_suborigin - 1] =
CStringGetTextDatum(opts.origin);
+ /* Always set the destination, default will be log. */
+ values[Anum_pg_subscription_sublogdestination - 1] =
+ CStringGetTextDatum(ConflictLogDestLabels[opts.logdest]);
+
+ /*
+ * If the conflict log destination includes 'table', generate an internal
+ * name using the subscription OID and determine the target namespace based
+ * on the current search path. Store the namespace OID and the conflict log
+ * format in the pg_subscription catalog tuple., then physically create
+ * the table.
+ */
+ if (opts.logdest == CONFLICT_LOG_DEST_TABLE ||
+ opts.logdest == CONFLICT_LOG_DEST_ALL)
+ {
+ char conflict_table_name[NAMEDATALEN];
+ Oid namespaceId, logrelid;
+
+ GetConflictLogTableName(conflict_table_name, subid);
+ namespaceId = RangeVarGetCreationNamespace(
+ makeRangeVar(NULL, conflict_table_name, -1));
+
+ /* Store the Oid returned from creation. */
+ logrelid = create_conflict_log_table(subid, stmt->subname, namespaceId,
+ conflict_table_name);
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(logrelid);
+ }
+ else
+ {
+ /* Destination is "log"; no table is needed. */
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(InvalidOid);
+ }
+
tup = heap_form_tuple(RelationGetDescr(rel), values, nulls);
/* Insert tuple into catalog. */
@@ -1410,7 +1478,8 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
SUBOPT_MAX_RETENTION_DURATION |
- SUBOPT_ORIGIN);
+ SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_DESTINATION);
parse_subscription_options(pstate, stmt->options,
supported_opts, &opts);
@@ -1665,6 +1734,72 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
origin = opts.origin;
}
+ if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_LOG_DESTINATION))
+ {
+ ConflictLogDest old_dest =
+ GetLogDestination(sub->logdestination);
+
+ if (opts.logdest != old_dest)
+ {
+ bool want_table =
+ (opts.logdest == CONFLICT_LOG_DEST_TABLE ||
+ opts.logdest == CONFLICT_LOG_DEST_ALL);
+ bool has_oldtable =
+ (old_dest == CONFLICT_LOG_DEST_TABLE ||
+ old_dest == CONFLICT_LOG_DEST_ALL);
+
+ values[Anum_pg_subscription_sublogdestination - 1] =
+ CStringGetTextDatum(ConflictLogDestLabels[opts.logdest]);
+ replaces[Anum_pg_subscription_sublogdestination - 1] = true;
+
+ if (want_table && !has_oldtable)
+ {
+ char relname[NAMEDATALEN];
+ Oid nspid;
+ Oid relid;
+
+ GetConflictLogTableName(relname, subid);
+ nspid = RangeVarGetCreationNamespace(makeRangeVar(
+ NULL, relname, -1));
+
+ relid = create_conflict_log_table(subid, sub->name,
+ nspid, relname);
+
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(relid);
+ replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
+ true;
+ }
+ else if (!want_table && has_oldtable)
+ {
+ ObjectAddress object;
+
+ /*
+ * Conflict log tables are recorded as internal
+ * dependencies of the subscription. Drop the
+ * table if it is not required anymore to avoid
+ * stale or orphaned relations.
+ *
+ * XXX: At present, only conflict log tables are
+ * managed this way. In future if we introduce
+ * additional internal dependencies, we may need
+ * a targeted deletion to avoid deletion of any
+ * other objects.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId,
+ subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(InvalidOid);
+ replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
+ true;
+ }
+ }
+ }
+
update_tuple = true;
break;
}
@@ -2027,6 +2162,7 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
Form_pg_subscription form;
List *rstates;
bool must_use_password;
+ ObjectAddress object;
/*
* The launcher may concurrently start a new worker for this subscription.
@@ -2184,6 +2320,19 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
/* Clean up dependencies */
deleteSharedDependencyRecordsFor(SubscriptionRelationId, subid, 0);
+ /*
+ * Conflict log tables are recorded as internal dependencies of the
+ * subscription. We must drop the dependent objects before the
+ * subscription itself is removed. By using
+ * PERFORM_DELETION_SKIP_ORIGINAL, we ensure that only the conflict log
+ * table is reaped while the subscription remains for the final deletion
+ * step.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId, subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
/* Remove any associated relation synchronization states. */
RemoveSubscriptionRel(subid, InvalidOid);
@@ -3188,3 +3337,185 @@ defGetStreamingMode(DefElem *def)
def->defname)));
return LOGICALREP_STREAM_OFF; /* keep compiler quiet */
}
+
+/*
+ * Builds the TupleDesc for the conflict log table.
+ */
+static TupleDesc
+create_conflict_log_table_tupdesc(void)
+{
+ TupleDesc tupdesc;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_CONFLICT_ATTR_NUM);
+
+ for (int i = 0; i < MAX_CONFLICT_ATTR_NUM; i++)
+ {
+ Oid type_oid = ConflictLogSchema[i].atttypid;
+
+ /*
+ * Special handling for the JSON array type for proper
+ * TupleDescInitEntry call.
+ */
+ if (type_oid == JSONARRAYOID)
+ type_oid = get_array_type(JSONOID);
+
+ TupleDescInitEntry(tupdesc, i + 1,
+ ConflictLogSchema[i].attname,
+ type_oid,
+ -1, 0);
+ }
+
+ return BlessTupleDesc(tupdesc);
+}
+
+/*
+ * Create conflict log table.
+ *
+ * The subscription owner becomes the owner of this table and has all
+ * privileges on it.
+ */
+static Oid
+create_conflict_log_table(Oid subid, char *subname, Oid namespaceId,
+ char *conflictrel)
+{
+ TupleDesc tupdesc;
+ Oid relid;
+ char comment[256];
+ ObjectAddress myself;
+ ObjectAddress subaddr;
+
+ /*
+ * Conflict log tables must be permanent relations. Disallow creation in
+ * temporary namespaces to ensure the same.
+ */
+ if (isTempNamespace(namespaceId))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("could not generate conflict log table \"%s\"",
+ conflictrel),
+ errdetail("Conflict log tables cannot be created in a temporary namespace."),
+ errhint("Ensure your 'search_path' is set to permanent schema.")));
+
+ /* Report an error if the specified conflict log table already exists. */
+ if (OidIsValid(get_relname_relid(conflictrel, namespaceId)))
+ ereport(ERROR,
+ (errcode(ERRCODE_DUPLICATE_TABLE),
+ errmsg("could not generate conflict log table \"%s.%s\"",
+ get_namespace_name(namespaceId), conflictrel),
+ errdetail("A table with the internally generated name already exists."),
+ errhint("Drop the existing table or change your 'search_path' to use a different schema.")));
+
+ /* Build the tuple descriptor for the new table. */
+ tupdesc = create_conflict_log_table_tupdesc();
+
+ /* Create conflict log table. */
+ relid = heap_create_with_catalog(conflictrel,
+ namespaceId,
+ 0,
+ InvalidOid,
+ InvalidOid,
+ InvalidOid,
+ GetUserId(),
+ HEAP_TABLE_AM_OID,
+ tupdesc,
+ NIL,
+ RELKIND_RELATION,
+ RELPERSISTENCE_PERMANENT,
+ false,
+ false,
+ ONCOMMIT_NOOP,
+ (Datum) 0,
+ false,
+ false,
+ false,
+ InvalidOid,
+ NULL);
+
+ /* Add a comments for the conflict log table. */
+ snprintf(comment, sizeof(comment),
+ "Conflict log table for subscription \"%s\"", subname);
+ CreateComments(relid, RelationRelationId, 0, comment);
+
+ /*
+ * Establish an internal dependency between the conflict log table and
+ * the subscription. By using DEPENDENCY_INTERNAL, we ensure the table
+ * is automatically reaped when the subscription is dropped. This also
+ * prevents the table from being dropped independently unless the
+ * subscription itself is removed.
+ */
+ ObjectAddressSet(myself, RelationRelationId, relid);
+ ObjectAddressSet(subaddr, SubscriptionRelationId, subid);
+ recordDependencyOn(&myself, &subaddr, DEPENDENCY_INTERNAL);
+
+ /* Release tuple descriptor memory. */
+ FreeTupleDesc(tupdesc);
+
+ return relid;
+}
+
+/*
+ * Format the standardized internal conflict log table name for a subscription
+ *
+ * Use the OID to prevent collisions during rename operations.
+ */
+void
+GetConflictLogTableName(char *dest, Oid subid)
+{
+ snprintf(dest, NAMEDATALEN, "conflict_log_table_%u", subid);
+}
+
+/*
+ * GetLogDestination
+ *
+ * Convert string to enum by comparing against standardized labels.
+ */
+ConflictLogDest
+GetLogDestination(const char *dest)
+{
+ /* Empty string or NULL defaults to LOG. */
+ if (dest == NULL || dest[0] == '\0')
+ return CONFLICT_LOG_DEST_LOG;
+
+ for (int i = CONFLICT_LOG_DEST_LOG; i <= CONFLICT_LOG_DEST_ALL; i++)
+ {
+ if (pg_strcasecmp(dest, ConflictLogDestLabels[i]) == 0)
+ return (ConflictLogDest) i;
+ }
+
+ /* Unrecognized string. */
+ return CONFLICT_LOG_DEST_INVALID;
+}
+
+/*
+ * Check if the specified relation is used as a conflict log table by any
+ * subscription.
+ */
+bool
+IsConflictLogTable(Oid relid)
+{
+ Relation rel;
+ TableScanDesc scan;
+ HeapTuple tup;
+ bool is_clt = false;
+
+ rel = table_open(SubscriptionRelationId, AccessShareLock);
+ scan = table_beginscan_catalog(rel, 0, NULL);
+
+ while (HeapTupleIsValid(tup = heap_getnext(scan, ForwardScanDirection)))
+ {
+ Form_pg_subscription subform = (Form_pg_subscription) GETSTRUCT(tup);
+
+ /* Direct Oid comparison from catalog */
+ if (OidIsValid(subform->subconflictlogrelid) &&
+ subform->subconflictlogrelid == relid)
+ {
+ is_clt = true;
+ break;
+ }
+ }
+
+ table_endscan(scan);
+ table_close(rel, AccessShareLock);
+
+ return is_clt;
+}
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index 36f24502842..cc80f0f661c 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -6806,7 +6806,7 @@ describeSubscriptions(const char *pattern, bool verbose)
printQueryOpt myopt = pset.popt;
static const bool translate_columns[] = {false, false, false, false,
false, false, false, false, false, false, false, false, false, false,
- false, false, false, false};
+ false, false, false, false, false};
if (pset.sversion < 100000)
{
@@ -6900,15 +6900,22 @@ describeSubscriptions(const char *pattern, bool verbose)
appendPQExpBuffer(&buf,
", subskiplsn AS \"%s\"\n",
gettext_noop("Skip LSN"));
+
+ /* Conflict log destination is supported in v19 and higher */
+ if (pset.sversion >= 190000)
+ {
+ appendPQExpBuffer(&buf,
+ ", sublogdestination AS \"%s\"\n",
+ gettext_noop("Conflict log destination"));
+ }
}
/* Only display subscriptions in current database. */
- appendPQExpBufferStr(&buf,
- "FROM pg_catalog.pg_subscription\n"
- "WHERE subdbid = (SELECT oid\n"
- " FROM pg_catalog.pg_database\n"
- " WHERE datname = pg_catalog.current_database())");
-
+ appendPQExpBuffer(&buf,
+ "FROM pg_catalog.pg_subscription "
+ "WHERE subdbid = (SELECT oid\n"
+ " FROM pg_catalog.pg_database\n"
+ " WHERE datname = pg_catalog.current_database())");
if (!validateSQLNamePattern(&buf, pattern, true, false,
NULL, "subname", NULL,
NULL,
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index b1ff6f6cd94..fd279d98b1b 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -2344,8 +2344,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("(", "PUBLICATION");
/* ALTER SUBSCRIPTION <name> SET ( */
else if (Matches("ALTER", "SUBSCRIPTION", MatchAny, MatchAnyN, "SET", "("))
- COMPLETE_WITH("binary", "disable_on_error", "failover",
- "max_retention_duration", "origin",
+ COMPLETE_WITH("binary", "conflict_log_destination", "disable_on_error",
+ "failover", "max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
"synchronous_commit", "two_phase");
@@ -3828,8 +3828,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("WITH (");
/* Complete "CREATE SUBSCRIPTION <name> ... WITH ( <opt>" */
else if (Matches("CREATE", "SUBSCRIPTION", MatchAnyN, "WITH", "("))
- COMPLETE_WITH("binary", "connect", "copy_data", "create_slot",
- "disable_on_error", "enabled", "failover",
+ COMPLETE_WITH("binary", "conflict_log_destination", "connect", "copy_data",
+ "create_slot", "disable_on_error", "enabled", "failover",
"max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index 55cb9b1eefa..55f4bfa0419 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -90,6 +90,7 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
* exceeded max_retention_duration, when
* defined */
+ Oid subconflictlogrelid; /* Relid of the conflict log table. */
#ifdef CATALOG_VARLEN /* variable-length fields start here */
/* Connection string to the publisher */
text subconninfo BKI_FORCE_NOT_NULL;
@@ -103,6 +104,14 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
/* List of publications subscribed to */
text subpublications[1] BKI_FORCE_NOT_NULL;
+ /*
+ * Strategy for logging replication conflicts:
+ * log - server log only,
+ * table - internal table only,
+ * all - both log and table.
+ */
+ text sublogdestination;
+
/* Only publish data originating from the specified origin */
text suborigin BKI_DEFAULT(LOGICALREP_ORIGIN_ANY);
#endif
@@ -152,12 +161,14 @@ typedef struct Subscription
* and the retention duration has not
* exceeded max_retention_duration, when
* defined */
+ Oid conflictrelid; /* conflict log table Oid */
char *conninfo; /* Connection string to the publisher */
char *slotname; /* Name of the replication slot */
char *synccommit; /* Synchronous commit setting for worker */
List *publications; /* List of publication names to subscribe to */
char *origin; /* Only publish data originating from the
* specified origin */
+ char *logdestination; /* Conflict log destination */
} Subscription;
#ifdef EXPOSE_TO_CLIENT_CODE
diff --git a/src/include/commands/subscriptioncmds.h b/src/include/commands/subscriptioncmds.h
index fb4e26a51a4..255e1e241b8 100644
--- a/src/include/commands/subscriptioncmds.h
+++ b/src/include/commands/subscriptioncmds.h
@@ -17,6 +17,7 @@
#include "catalog/objectaddress.h"
#include "parser/parse_node.h"
+#include "replication/conflict.h"
extern ObjectAddress CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bool isTopLevel);
@@ -36,4 +37,8 @@ extern void CheckSubDeadTupleRetention(bool check_guc, bool sub_disabled,
bool retention_active,
bool max_retention_set);
+extern void GetConflictLogTableName(char *dest, Oid subid);
+extern ConflictLogDest GetLogDestination(const char *dest);
+extern bool IsConflictLogTable(Oid relid);
+
#endif /* SUBSCRIPTIONCMDS_H */
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index c8fbf9e51b8..70f8744b381 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -10,6 +10,7 @@
#define CONFLICT_H
#include "access/xlogdefs.h"
+#include "catalog/pg_type.h"
#include "nodes/pg_list.h"
#include "utils/timestamp.h"
@@ -79,6 +80,55 @@ typedef struct ConflictTupleInfo
* conflicting local row occurred */
} ConflictTupleInfo;
+/*
+ * Conflict log destination types.
+ *
+ * Internally we use these enum values for fast comparison, but we store
+ * the string equivalent in pg_subscription.sublogdestination.
+ */
+typedef enum ConflictLogDest
+{
+ CONFLICT_LOG_DEST_INVALID = 0,
+ CONFLICT_LOG_DEST_LOG, /* "log" (default) */
+ CONFLICT_LOG_DEST_TABLE, /* "table" */
+ CONFLICT_LOG_DEST_ALL /* "all" */
+} ConflictLogDest;
+
+/*
+ * Array mapping for converting internal enum to string.
+ */
+static const char *const ConflictLogDestLabels[] = {
+ [CONFLICT_LOG_DEST_LOG] = "log",
+ [CONFLICT_LOG_DEST_TABLE] = "table",
+ [CONFLICT_LOG_DEST_ALL] = "all"
+};
+
+/* Structure to hold metadata for one column of the conflict log table */
+typedef struct ConflictLogColumnDef
+{
+ const char *attname; /* Column name */
+ Oid atttypid; /* Data type OID */
+} ConflictLogColumnDef;
+
+/* The single source of truth for the conflict log table schema */
+static const ConflictLogColumnDef ConflictLogSchema[] =
+{
+ { .attname = "relid", .atttypid = OIDOID },
+ { .attname = "schemaname", .atttypid = TEXTOID },
+ { .attname = "relname", .atttypid = TEXTOID },
+ { .attname = "conflict_type", .atttypid = TEXTOID },
+ { .attname = "remote_xid", .atttypid = XIDOID },
+ { .attname = "remote_commit_lsn",.atttypid = LSNOID },
+ { .attname = "remote_commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "remote_origin", .atttypid = TEXTOID },
+ { .attname = "replica_identity", .atttypid = JSONOID },
+ { .attname = "remote_tuple", .atttypid = JSONOID },
+ { .attname = "local_conflicts", .atttypid = JSONARRAYOID }
+};
+
+/* Define the count using the array size */
+#define MAX_CONFLICT_ATTR_NUM (sizeof(ConflictLogSchema) / sizeof(ConflictLogSchema[0]))
+
extern bool GetTupleTransactionInfo(TupleTableSlot *localslot,
TransactionId *xmin,
RepOriginId *localorigin,
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index 327d1e7731f..4ab58d90925 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -116,18 +116,18 @@ CREATE SUBSCRIPTION regress_testsub4 CONNECTION 'dbname=regress_doesnotexist' PU
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub4 SET (origin = any);
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub3;
@@ -145,10 +145,10 @@ ALTER SUBSCRIPTION regress_testsub CONNECTION 'foobar';
ERROR: invalid connection string syntax: missing "=" after "foobar" in connection info string
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET PUBLICATION testpub2, testpub3 WITH (refresh = false);
@@ -157,10 +157,10 @@ ALTER SUBSCRIPTION regress_testsub SET (slot_name = 'newname');
ALTER SUBSCRIPTION regress_testsub SET (password_required = false);
ALTER SUBSCRIPTION regress_testsub SET (run_as_owner = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (password_required = true);
@@ -176,10 +176,10 @@ ERROR: unrecognized subscription parameter: "create_slot"
-- ok
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/12345');
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345 | log
(1 row)
-- ok - with lsn = NONE
@@ -188,10 +188,10 @@ ALTER SUBSCRIPTION regress_testsub SKIP (lsn = NONE);
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/0');
ERROR: invalid WAL location (LSN): 0/0
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
BEGIN;
@@ -223,10 +223,10 @@ ALTER SUBSCRIPTION regress_testsub_foo SET (synchronous_commit = foobar);
ERROR: invalid value for parameter "synchronous_commit": "foobar"
HINT: Available values: local, remote_write, remote_apply, on, off.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
----------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+---------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
-- rename back to keep the rest simple
@@ -255,19 +255,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (binary = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -279,27 +279,27 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = parallel);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- fail - publication already exists
@@ -314,10 +314,10 @@ ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refr
ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refresh = false);
ERROR: publication "testpub1" is already in subscription "regress_testsub"
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- fail - publication used more than once
@@ -332,10 +332,10 @@ ERROR: publication "testpub3" is not in subscription "regress_testsub"
-- ok - delete publications
ALTER SUBSCRIPTION regress_testsub DROP PUBLICATION testpub1, testpub2 WITH (refresh = false);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -371,19 +371,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- we can alter streaming when two_phase enabled
ALTER SUBSCRIPTION regress_testsub SET (streaming = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -393,10 +393,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -409,18 +409,18 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (disable_on_error = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -433,10 +433,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -450,19 +450,19 @@ NOTICE: max_retention_duration is ineffective when retain_dead_tuples is disabl
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- ok
ALTER SUBSCRIPTION regress_testsub SET (max_retention_duration = 0);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -517,7 +517,159 @@ COMMIT;
-- ok, owning it is enough for this stuff
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG DESTINATION TESTS
+--
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+-- fail - unrecognized format value
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
+ERROR: unrecognized conflict_log_destination value: "invalid"
+HINT: Valid values are "log", "table", and "all".
+-- verify sublogdestination is 'log' and relid is 0 (InvalidOid) for default case
+CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+SELECT subname, sublogdestination, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
+ subname | sublogdestination | subconflictlogrelid
+------------------------------+-------------------+---------------------
+ regress_conflict_log_default | log | 0
+(1 row)
+
+-- verify empty string defaults to 'log'
+CREATE SUBSCRIPTION regress_conflict_empty_str CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = '');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+SELECT subname, sublogdestination, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
+ subname | sublogdestination | subconflictlogrelid
+----------------------------+-------------------+---------------------
+ regress_conflict_empty_str | log | 0
+(1 row)
+
+-- this should generate an internal table named conflict_log_table_$subid$
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- check metadata in pg_subscription: destination should be 'table' and relid valid
+SELECT subname, sublogdestination, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname | sublogdestination | has_relid
+------------------------+-------------------+-----------
+ regress_conflict_test1 | table | t
+(1 row)
+
+-- verify the physical table exists and its OID matches subconflictlogrelid
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND c.oid = s.subconflictlogrelid;
+ count
+-------
+ 1
+(1 row)
+
+-- check if the internal table has the correct schema (11 columns)
+SELECT count(*)
+FROM pg_attribute a
+JOIN pg_class c ON a.attrelid = c.oid
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0;
+ count
+-------
+ 11
+(1 row)
+
+-- ALTER: State transitions
+-- transition from 'log' to 'all'
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
+-- verify metadata after ALTER (destination should be 'all')
+SELECT subname, sublogdestination, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | sublogdestination | has_relid
+------------------------+-------------------+-----------
+ regress_conflict_test2 | all | t
+(1 row)
+
+-- transition from 'all' to 'table' (should NOT drop the table, only change destination string)
+SELECT subconflictlogrelid AS old_relid FROM pg_subscription WHERE subname = 'regress_conflict_test2' \gset
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'table');
+SELECT sublogdestination, subconflictlogrelid = :old_relid AS relid_unchanged
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ sublogdestination | relid_unchanged
+-------------------+-----------------
+ table | t
+(1 row)
+
+-- transition from 'table' to 'log' (should drop the table and clear relid)
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log');
+SELECT sublogdestination, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ sublogdestination | subconflictlogrelid
+-------------------+---------------------
+ log | 0
+(1 row)
+
+-- verify the physical table is gone
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test2';
+ count
+-------
+ 0
+(1 row)
+
+-- ensure drop table not allowed and DROP SUBSCRIPTION reaps the table
+-- re-enable table logging for verification
+ALTER SUBSCRIPTION regress_conflict_test1 SET (conflict_log_destination = 'table');
+-- fail - drop table not allowed due to internal dependency
+-- use DO block to hide OID in error message
+DO $$
+BEGIN
+ EXECUTE 'DROP TABLE ' || (SELECT 'conflict_log_table_' || oid FROM pg_subscription WHERE subname = 'regress_conflict_test1');
+EXCEPTION WHEN dependent_objects_still_exist THEN
+ RAISE NOTICE 'captured expected error: dependent_objects_still_exist';
+END $$;
+NOTICE: captured expected error: dependent_objects_still_exist
+-- PUBLICATION: Verify internal tables are not publishable
+-- pg_relation_is_publishable should return false for internal conflict log tables
+SELECT pg_relation_is_publishable(subconflictlogrelid)
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ pg_relation_is_publishable
+----------------------------
+ f
+(1 row)
+
+-- CLEANUP: Proper drop reaps the table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+-- Verify the table OID for reap check
+SELECT 'conflict_log_table_' || oid AS internal_tablename FROM pg_subscription WHERE subname = 'regress_conflict_test1' \gset
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the internal table was reaped via dependency
+SELECT to_regclass(:'internal_tablename');
+ to_regclass
+-------------
+
+(1 row)
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_log_default DISABLE;
+ALTER SUBSCRIPTION regress_conflict_log_default SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_log_default;
+ALTER SUBSCRIPTION regress_conflict_empty_str DISABLE;
+ALTER SUBSCRIPTION regress_conflict_empty_str SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_empty_str;
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
RESET SESSION AUTHORIZATION;
+DROP SCHEMA clt;
+ERROR: schema "clt" does not exist
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
DROP ROLE regress_subscription_user3;
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index ef0c298d2df..3359ff8be5c 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -365,7 +365,116 @@ COMMIT;
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG DESTINATION TESTS
+--
+
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+
+-- fail - unrecognized format value
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
+
+-- verify sublogdestination is 'log' and relid is 0 (InvalidOid) for default case
+CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+SELECT subname, sublogdestination, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
+
+-- verify empty string defaults to 'log'
+CREATE SUBSCRIPTION regress_conflict_empty_str CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = '');
+SELECT subname, sublogdestination, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
+
+-- this should generate an internal table named conflict_log_table_$subid$
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+
+-- check metadata in pg_subscription: destination should be 'table' and relid valid
+SELECT subname, sublogdestination, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- verify the physical table exists and its OID matches subconflictlogrelid
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND c.oid = s.subconflictlogrelid;
+
+-- check if the internal table has the correct schema (11 columns)
+SELECT count(*)
+FROM pg_attribute a
+JOIN pg_class c ON a.attrelid = c.oid
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0;
+
+-- ALTER: State transitions
+-- transition from 'log' to 'all'
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
+
+-- verify metadata after ALTER (destination should be 'all')
+SELECT subname, sublogdestination, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'all' to 'table' (should NOT drop the table, only change destination string)
+SELECT subconflictlogrelid AS old_relid FROM pg_subscription WHERE subname = 'regress_conflict_test2' \gset
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'table');
+SELECT sublogdestination, subconflictlogrelid = :old_relid AS relid_unchanged
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'table' to 'log' (should drop the table and clear relid)
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log');
+SELECT sublogdestination, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- verify the physical table is gone
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test2';
+
+-- ensure drop table not allowed and DROP SUBSCRIPTION reaps the table
+-- re-enable table logging for verification
+ALTER SUBSCRIPTION regress_conflict_test1 SET (conflict_log_destination = 'table');
+
+-- fail - drop table not allowed due to internal dependency
+-- use DO block to hide OID in error message
+DO $$
+BEGIN
+ EXECUTE 'DROP TABLE ' || (SELECT 'conflict_log_table_' || oid FROM pg_subscription WHERE subname = 'regress_conflict_test1');
+EXCEPTION WHEN dependent_objects_still_exist THEN
+ RAISE NOTICE 'captured expected error: dependent_objects_still_exist';
+END $$;
+
+-- PUBLICATION: Verify internal tables are not publishable
+-- pg_relation_is_publishable should return false for internal conflict log tables
+SELECT pg_relation_is_publishable(subconflictlogrelid)
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- CLEANUP: Proper drop reaps the table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+
+-- Verify the table OID for reap check
+SELECT 'conflict_log_table_' || oid AS internal_tablename FROM pg_subscription WHERE subname = 'regress_conflict_test1' \gset
+
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the internal table was reaped via dependency
+SELECT to_regclass(:'internal_tablename');
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_log_default DISABLE;
+ALTER SUBSCRIPTION regress_conflict_log_default SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_log_default;
+
+ALTER SUBSCRIPTION regress_conflict_empty_str DISABLE;
+ALTER SUBSCRIPTION regress_conflict_empty_str SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_empty_str;
+
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+
RESET SESSION AUTHORIZATION;
+DROP SCHEMA clt;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
DROP ROLE regress_subscription_user3;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 04845d5e680..21826be5bd7 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -500,6 +500,7 @@ ConditionVariableMinimallyPadded
ConditionalStack
ConfigData
ConfigVariable
+ConflictLogColumnDef
ConflictTupleInfo
ConflictType
ConnCacheEntry
--
2.43.0
[text/x-patch] v15-0003-Doccumentation-patch.patch (11.3K, ../../CALDaNm1zR1L2oq-LqYEcc8-wTZYjfJsiaTC_jQ8pGwbm0fv+3Q@mail.gmail.com/3-v15-0003-Doccumentation-patch.patch)
download | inline diff:
From d230616135c4d6313d3e9bd72d5d3cd2b5362433 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Sun, 21 Dec 2025 18:51:57 +0530
Subject: [PATCH v15 3/5] Doccumentation patch
---
doc/src/sgml/logical-replication.sgml | 122 +++++++++++++++++++++-
doc/src/sgml/ref/alter_subscription.sgml | 12 ++-
doc/src/sgml/ref/create_subscription.sgml | 36 +++++++
3 files changed, 164 insertions(+), 6 deletions(-)
diff --git a/doc/src/sgml/logical-replication.sgml b/doc/src/sgml/logical-replication.sgml
index b3faaa675ef..5544f9beb02 100644
--- a/doc/src/sgml/logical-replication.sgml
+++ b/doc/src/sgml/logical-replication.sgml
@@ -253,7 +253,11 @@
The subscription is added using <link linkend="sql-createsubscription"><command>CREATE SUBSCRIPTION</command></link> and
can be stopped/resumed at any time using the
<link linkend="sql-altersubscription"><command>ALTER SUBSCRIPTION</command></link> command and removed using
- <link linkend="sql-dropsubscription"><command>DROP SUBSCRIPTION</command></link>.
+ <link linkend="sql-dropsubscription"><command>DROP SUBSCRIPTION</command></link>. When the
+ <literal>conflict_log_destination</literal> parameter is set to <literal>table</literal>
+ or <literal>all</literal>, the system automatically manages a dedicated conflict
+ storage table. This table is dropped automatically when the subscription is
+ removed via <command>DROP SUBSCRIPTION</command>.
</para>
<para>
@@ -289,6 +293,16 @@
option of <command>CREATE SUBSCRIPTION</command> for details.
</para>
+ <para>
+ Conflicts that occur during replication are typically logged as plain text
+ in the server log, which can be difficult for automated monitoring and
+ analysis. The <command>CREATE SUBSCRIPTION</command> command provides the
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination">
+ <literal>conflict_log_destination</literal></link> option to record detailed
+ conflict information in a structured, queryable format, significantly
+ improving post-mortem analysis and operational visibility of the replication
+ setup by logging to a system-managed table.
+
<sect2 id="logical-replication-subscription-slot">
<title>Logical Replication Slot Management</title>
@@ -2006,9 +2020,14 @@ Publications:
</para>
<para>
- Additional logging is triggered, and the conflict statistics are collected (displayed in the
- <link linkend="monitoring-pg-stat-subscription-stats"><structname>pg_stat_subscription_stats</structname></link> view)
- in the following <firstterm>conflict</firstterm> cases:
+ Additional logging is triggered, and the conflict statistics are collected
+ (displayed in the <link linkend="monitoring-pg-stat-subscription-stats">
+ <structname>pg_stat_subscription_stats</structname></link> view)
+ in the following <firstterm>conflict</firstterm> cases. If the subscription
+ was created with the <literal>conflict_log_destination</literal> set to
+ <literal>table</literal>, detailed conflict information is also inserted
+ into an internally managed table named <literal>conflict_log_table_<subscription_oid></literal>,
+ providing a structured record of all conflicts.
<variablelist>
<varlistentry id="conflict-insert-exists" xreflabel="insert_exists">
<term><literal>insert_exists</literal></term>
@@ -2117,6 +2136,92 @@ Publications:
log.
</para>
+ <para>
+ When the <literal>conflict_log_destination</literal> is set to
+ <literal>table</literal>, the system automatically creates a new table with
+ a predefined schema to log conflict details. This table is created in the
+ owner's schema, is owned by the subscription owner, and logs system fields.
+ The schema of this table is detailed in
+ <xref linkend="logical-replication-conflict-log-schema"/>.
+ </para>
+
+ <table id="logical-replication-conflict-log-schema">
+ <title>Conflict Log History Table Schema</title>
+ <tgroup cols="3">
+ <thead>
+ <row>
+ <entry>Column</entry>
+ <entry>Type</entry>
+ <entry>Description</entry>
+ </row>
+ </thead>
+ <tbody>
+ <row>
+ <entry><literal>relid</literal></entry>
+ <entry><type>oid</type></entry>
+ <entry>The OID of the local table where the conflict occurred.</entry>
+ </row>
+ <row>
+ <entry><literal>schemaname</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The schema name of the conflicting table.</entry>
+ </row>
+ <row>
+ <entry><literal>relname</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The name of the conflicting table.</entry>
+ </row>
+ <row>
+ <entry><literal>conflict_type</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The type of conflict that occurred (e.g., <literal>insert_exists</literal>).</entry>
+ </row>
+ <row>
+ <entry><literal>remote_xid</literal></entry>
+ <entry><type>xid</type></entry>
+ <entry>The remote transaction ID that caused the conflict.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_commit_lsn</literal></entry>
+ <entry><type>pg_lsn</type></entry>
+ <entry>The final LSN of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_commit_ts</literal></entry>
+ <entry><type>timestamptz</type></entry>
+ <entry>The remote commit timestamp of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_origin</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The origin of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_tuple</literal></entry>
+ <entry><type>json</type></entry>
+ <entry>The JSON representation of the incoming remote row that caused the conflict.</entry>
+ </row>
+ <row>
+ <entry><literal>local_conflicts</literal></entry>
+ <entry><type>json[]</type></entry>
+ <entry>
+ An array of JSON objects representing the local state for each conflict attempt.
+ Each object includes the local transaction ID (<literal>xid</literal>), commit
+ timestamp (<literal>commit_ts</literal>), origin (<literal>origin</literal>),
+ conflicting key data (<literal>key</literal>), and the full local row
+ image (<literal>tuple</literal>).
+ </entry>
+ </row>
+ </tbody>
+ </tgroup>
+ </table>
+
+ <para>
+ The conflicting row data, including the original local tuple and
+ the remote tuple, is stored in <type>JSON</type> columns (<literal>local_tuple</literal>
+ and <literal>remote_tuple</literal>) for flexible querying and analysis.
+ </para>
+
<para>
The log format for logical replication conflicts is as follows:
<synopsis>
@@ -2412,6 +2517,15 @@ CONTEXT: processing remote data for replication origin "pg_16395" during "INSER
key or replica identity defined for it.
</para>
</listitem>
+
+ <listitem>
+ <para>
+ The internal table automatically created when <literal>conflict_log_destination</literal>
+ is set to <literal>table</literal> or <literal>all</literal> is excluded from
+ logical replication. It will not be published, even if a publication on the
+ subscriber is defined using <literal>FOR ALL TABLES</literal>.
+ </para>
+ </listitem>
</itemizedlist>
</sect1>
diff --git a/doc/src/sgml/ref/alter_subscription.sgml b/doc/src/sgml/ref/alter_subscription.sgml
index 27c06439f4f..2f3bb0618f5 100644
--- a/doc/src/sgml/ref/alter_subscription.sgml
+++ b/doc/src/sgml/ref/alter_subscription.sgml
@@ -280,8 +280,9 @@ ALTER SUBSCRIPTION <replaceable class="parameter">name</replaceable> RENAME TO <
<link linkend="sql-createsubscription-params-with-origin"><literal>origin</literal></link>,
<link linkend="sql-createsubscription-params-with-failover"><literal>failover</literal></link>,
<link linkend="sql-createsubscription-params-with-two-phase"><literal>two_phase</literal></link>,
- <link linkend="sql-createsubscription-params-with-retain-dead-tuples"><literal>retain_dead_tuples</literal></link>, and
- <link linkend="sql-createsubscription-params-with-max-retention-duration"><literal>max_retention_duration</literal></link>.
+ <link linkend="sql-createsubscription-params-with-retain-dead-tuples"><literal>retain_dead_tuples</literal></link>,
+ <link linkend="sql-createsubscription-params-with-max-retention-duration"><literal>max_retention_duration</literal></link> and,
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>.
Only a superuser can set <literal>password_required = false</literal>.
</para>
@@ -339,6 +340,13 @@ ALTER SUBSCRIPTION <replaceable class="parameter">name</replaceable> RENAME TO <
<quote><literal>pg_conflict_detection</literal></quote>, created to retain
dead tuples for conflict detection, will be dropped.
</para>
+
+ <para>
+ When switching <literal>conflict_log_destination</literal> to <literal>table</literal>,
+ the system will ensure the internal logging table exists. If switching away
+ from <literal>table</literal>, the logging stops, but the previously recorded
+ data remains until the subscription is dropped.
+ </para>
</listitem>
</varlistentry>
diff --git a/doc/src/sgml/ref/create_subscription.sgml b/doc/src/sgml/ref/create_subscription.sgml
index 197be0c6f6b..3fa891bc4ae 100644
--- a/doc/src/sgml/ref/create_subscription.sgml
+++ b/doc/src/sgml/ref/create_subscription.sgml
@@ -274,6 +274,42 @@ CREATE SUBSCRIPTION <replaceable class="parameter">subscription_name</replaceabl
</listitem>
</varlistentry>
+ <varlistentry id="sql-createsubscription-params-with-conflict-log-destination">
+ <term><literal>conflict_log_destination</literal> (<type>enum</type>)</term>
+ <listitem>
+ <para>
+ Specifies the destination for recording logical replication conflicts.
+ The supported values are <literal>log</literal>, <literal>table</literal>,
+ and <literal>all</literal>. The default is <literal>log</literal>.
+ </para>
+ <para>
+ The available destinations are:
+ <itemizedlist>
+ <listitem>
+ <para>
+ <literal>log</literal>: Conflict details are recorded in the server log.
+ This is the default behavior.
+ </para>
+ </listitem>
+ <listitem>
+ <para>
+ <literal>table</literal>: The system automatically creates a structured table named
+ <literal>pg_conflict_<subid></literal> in the subscription owner's schema.
+ This allows for easy querying and analysis of conflicts. This table is
+ automatically dropped when the subscription is removed.
+ </para>
+ </listitem>
+ <listitem>
+ <para>
+ <literal>all</literal>: Records the conflict information to both the server log
+ and the dedicated conflict table.
+ </para>
+ </listitem>
+ </itemizedlist>
+ </para>
+ </listitem>
+ </varlistentry>
+
<varlistentry id="sql-createsubscription-params-with-streaming">
<term><literal>streaming</literal> (<type>enum</type>)</term>
<listitem>
--
2.43.0
[text/x-patch] v15-0002-Implement-the-conflict-insertion-infrastructure-.patch (34.3K, ../../CALDaNm1zR1L2oq-LqYEcc8-wTZYjfJsiaTC_jQ8pGwbm0fv+3Q@mail.gmail.com/4-v15-0002-Implement-the-conflict-insertion-infrastructure-.patch)
download | inline diff:
From a00a90a9f139b06b7d442b99e9de6e4b715fcd59 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Sat, 20 Dec 2025 15:20:09 +0530
Subject: [PATCH v15 2/5] Implement the conflict insertion infrastructure into
the conflict log table
Note: A single remote tuple may conflict with multiple local tuples when conflict type
is CT_MULTIPLE_UNIQUE_CONFLICTS, so for handling this case we create a single row in
conflict log table with respect to each remote conflict tuple even if it conflicts with
multiple local tuples and we store the multiple conflict tuples as a single JSON array
element in format as
[ { "xid": "1001", "commit_ts": "...", "origin": "...", "tuple": {...} }, ... ]
We can extract the elements of the local tuples from the conflict log table row
as given in below example.
SELECT remote_xid, relname, remote_origin, local_conflicts[1] ->> 'xid' AS local_xid,
local_conflicts[1] ->> 'tuple' AS local_tuple
FROM myschema.conflict_log_history2;
remote_xid | relname | remote_origin | local_xid | local_tuple
------------+----------+---------------+-----------+---------------------
760 | test | pg_16406 | 771 | {"a":1,"b":10}
765 | conf_tab | pg_16406 | 775 | {"a":2,"b":2,"c":2}
---
src/backend/replication/logical/conflict.c | 596 ++++++++++++++++++-
src/backend/replication/logical/launcher.c | 1 +
src/backend/replication/logical/worker.c | 32 +-
src/include/replication/conflict.h | 17 +-
src/include/replication/worker_internal.h | 7 +
src/test/subscription/t/037_conflict_dest.pl | 181 ++++++
6 files changed, 800 insertions(+), 34 deletions(-)
create mode 100644 src/test/subscription/t/037_conflict_dest.pl
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 16695592265..7cc07a64427 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -15,13 +15,22 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/tableam.h"
+#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "pgstat.h"
#include "replication/conflict.h"
#include "replication/worker_internal.h"
#include "storage/lmgr.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/lsyscache.h"
+#include "utils/pg_lsn.h"
+#include "utils/jsonb.h"
+
+#define MAX_LOCAL_CONFLICT_INFO_ATTRS 5
static const char *const ConflictTypeNames[] = {
[CT_INSERT_EXISTS] = "insert_exists",
@@ -50,8 +59,27 @@ static char *build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *localslot,
TupleTableSlot *remoteslot,
Oid indexoid);
+static void build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull);
static char *build_index_value_desc(EState *estate, Relation localrel,
TupleTableSlot *slot, Oid indexoid);
+static Datum tuple_table_slot_to_json_datum(TupleTableSlot *slot);
+static Datum tuple_table_slot_to_indextup_json(EState *estate,
+ Relation localrel,
+ Oid replica_index,
+ TupleTableSlot *slot);
+static TupleDesc build_conflict_tupledesc(void);
+static Datum build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples);
+static void prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot);
/*
* Get the xmin and commit timestamp data (origin and timestamp) associated
@@ -105,11 +133,19 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
ConflictType type, TupleTableSlot *searchslot,
TupleTableSlot *remoteslot, List *conflicttuples)
{
- Relation localrel = relinfo->ri_RelationDesc;
- StringInfoData err_detail;
+ Relation localrel = relinfo->ri_RelationDesc;
+ StringInfoData err_detail;
+ ConflictLogDest dest;
+ Relation conflictlogrel;
initStringInfo(&err_detail);
+ /*
+ * Get both the conflict log destination and the opened conflict log
+ * relation for insertion.
+ */
+ conflictlogrel = GetConflictLogTableInfo(&dest);
+
/* Form errdetail message by combining conflicting tuples information. */
foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
errdetail_apply_conflict(estate, relinfo, type, searchslot,
@@ -120,15 +156,63 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
conflicttuple->ts,
&err_detail);
+ /* Insert to table if destination is 'table' or 'all' */
+ if (conflictlogrel)
+ {
+ Assert(dest == CONFLICT_LOG_DEST_TABLE ||
+ dest == CONFLICT_LOG_DEST_ALL);
+
+ if (ValidateConflictLogTable(conflictlogrel))
+ {
+ /*
+ * Prepare the conflict log tuple. If the error level is below
+ * ERROR, insert it immediately. Otherwise, defer the insertion to
+ * a new transaction after the current one aborts, ensuring the
+ * insertion of the log tuple is not rolled back.
+ */
+ prepare_conflict_log_tuple(estate,
+ relinfo->ri_RelationDesc,
+ conflictlogrel,
+ type,
+ searchslot,
+ conflicttuples,
+ remoteslot);
+ if (elevel < ERROR)
+ InsertConflictLogTuple(conflictlogrel);
+ }
+
+ table_close(conflictlogrel, RowExclusiveLock);
+ }
+
pgstat_report_subscription_conflict(MySubscription->oid, type);
- ereport(elevel,
- errcode_apply_conflict(type),
- errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
- get_namespace_name(RelationGetNamespace(localrel)),
- RelationGetRelationName(localrel),
- ConflictTypeNames[type]),
- errdetail_internal("%s", err_detail.data));
+ /* Decide what detail to show in server logs. */
+ if (dest == CONFLICT_LOG_DEST_LOG || dest == CONFLICT_LOG_DEST_ALL)
+ {
+ /* Standard reporting with full internal details. */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail_internal("%s", err_detail.data));
+ }
+ else
+ {
+ /*
+ * 'table' only: Report the error msg but omit raw tuple data from
+ * server logs since it's already captured in the internal table.
+ */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail("Conflict details logged to internal table with OID %u.",
+ MySubscription->conflictrelid));
+ }
}
/*
@@ -162,6 +246,143 @@ InitConflictIndexes(ResultRelInfo *relInfo)
relInfo->ri_onConflictArbiterIndexes = uniqueIndexes;
}
+/*
+ * GetConflictLogTableInfo
+ *
+ * Fetches conflict logging metadata from the cached MySubscription pointer.
+ * Sets the destination enum in *log_dest and, if applicable, opens and
+ * returns the relation handle for the internal log table.
+ */
+Relation
+GetConflictLogTableInfo(ConflictLogDest *log_dest)
+{
+ Oid conflictlogrelid;
+ Relation conflictlogrel = NULL;
+
+ /*
+ * Convert the text log destination to the internal enum. MySubscription
+ * already contains the data from pg_subscription.
+ */
+ *log_dest = GetLogDestination(MySubscription->logdestination);
+ conflictlogrelid = MySubscription->conflictrelid;
+
+ /* If destination is 'log' only, no table to open. */
+ if (*log_dest == CONFLICT_LOG_DEST_LOG)
+ return NULL;
+
+ Assert(OidIsValid(conflictlogrelid));
+
+ conflictlogrel = table_open(conflictlogrelid, RowExclusiveLock);
+
+ /* Conflict log table is dropped or not accessible. */
+ if (conflictlogrel == NULL)
+ ereport(WARNING,
+ (errcode(ERRCODE_UNDEFINED_TABLE),
+ errmsg("conflict log table with OID %u does not exist",
+ conflictlogrelid)));
+
+ return conflictlogrel;
+}
+
+/*
+ * InsertConflictLogTuple
+ *
+ * Insert conflict log tuple into the conflict log table. It uses
+ * HEAP_INSERT_NO_LOGICAL to explicitly block logical decoding of the tuple
+ * inserted into the conflict log table.
+ */
+void
+InsertConflictLogTuple(Relation conflictlogrel)
+{
+ int options = HEAP_INSERT_NO_LOGICAL;
+
+ /* A valid tuple must be prepared and stored in MyLogicalRepWorker. */
+ Assert(MyLogicalRepWorker->conflict_log_tuple != NULL);
+
+ heap_insert(conflictlogrel, MyLogicalRepWorker->conflict_log_tuple,
+ GetCurrentCommandId(true), options, NULL);
+
+ /* Free conflict log tuple. */
+ heap_freetuple(MyLogicalRepWorker->conflict_log_tuple);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+}
+
+/*
+ * ValidateConflictLogTable - Validate conflict log table schema
+ *
+ * Checks whether the table definition including its column names, data
+ * types, and column ordering meet the requirements for conflict log
+ * table.
+ */
+bool
+ValidateConflictLogTable(Relation rel)
+{
+ Relation pg_attribute;
+ HeapTuple atup;
+ ScanKeyData scankey;
+ SysScanDesc scan;
+ int attcnt = 0;
+ bool tbl_ok = true;
+
+ pg_attribute = table_open(AttributeRelationId, AccessShareLock);
+ ScanKeyInit(&scankey,
+ Anum_pg_attribute_attrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationGetRelid(rel)));
+
+ scan = systable_beginscan(pg_attribute, AttributeRelidNumIndexId, true,
+ SnapshotSelf, 1, &scankey);
+
+ /* We only need to check up to MAX_CONFLICT_ATTR_NUM attributes */
+ while (HeapTupleIsValid(atup = systable_getnext(scan)))
+ {
+ const ConflictLogColumnDef *expected;
+ int schema_idx;
+ Form_pg_attribute attForm = (Form_pg_attribute) GETSTRUCT(atup);
+
+ /* Skip system columns and dropped columns */
+ if (attForm->attnum < 1 || attForm->attisdropped)
+ continue;
+
+ attcnt++;
+
+ /* attnum 1 corresponds to index 0 in ConflictLogSchema */
+ schema_idx = attForm->attnum - 1;
+
+ /* Check against the central schema definition */
+ if (schema_idx >= MAX_CONFLICT_ATTR_NUM)
+ {
+ /* Found an extra column beyond the required set */
+ tbl_ok = false;
+ break;
+ }
+
+ expected = &ConflictLogSchema[schema_idx];
+
+ if (attForm->atttypid != expected->atttypid ||
+ strcmp(NameStr(attForm->attname), expected->attname) != 0)
+ {
+ tbl_ok = false;
+ break;
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_attribute, AccessShareLock);
+
+ if (attcnt != MAX_CONFLICT_ATTR_NUM || !tbl_ok)
+ {
+ ereport(WARNING,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("conflict log table \"%s.%s\" structure changed, skipping insertion",
+ get_namespace_name(RelationGetNamespace(rel)),
+ RelationGetRelationName(rel)));
+ return false;
+ }
+
+ return true;
+}
+
/*
* Add SQLSTATE error code to the current conflict report.
*/
@@ -472,6 +693,40 @@ build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
return tuple_value.data;
}
+/*
+ * Helper function to extract the "raw" index key Datums and their null flags
+ * from a TupleTableSlot, given an already open index descriptor.
+ * This is the reusable core logic.
+ */
+static void
+build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull)
+{
+ TupleTableSlot *tableslot = slot;
+
+ /*
+ * If the slot is a virtual slot, copy it into a heap tuple slot as
+ * FormIndexDatum only works with heap tuple slots.
+ */
+ if (TTS_IS_VIRTUAL(slot))
+ {
+ /* Slot is created within the EState's tuple table */
+ tableslot = table_slot_create(localrel, &estate->es_tupleTable);
+ tableslot = ExecCopySlot(tableslot, slot);
+ }
+
+ /*
+ * Initialize ecxt_scantuple for potential use in FormIndexDatum
+ */
+ GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+
+ /* Form the index datums */
+ FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values,
+ isnull);
+}
+
/*
* Helper functions to construct a string describing the contents of an index
* entry. See BuildIndexValueDescription for details.
@@ -487,41 +742,318 @@ build_index_value_desc(EState *estate, Relation localrel, TupleTableSlot *slot,
Relation indexDesc;
Datum values[INDEX_MAX_KEYS];
bool isnull[INDEX_MAX_KEYS];
- TupleTableSlot *tableslot = slot;
- if (!tableslot)
+ if (!slot)
return NULL;
Assert(CheckRelationOidLockedByMe(indexoid, RowExclusiveLock, true));
indexDesc = index_open(indexoid, NoLock);
- /*
- * If the slot is a virtual slot, copy it into a heap tuple slot as
- * FormIndexDatum only works with heap tuple slots.
- */
- if (TTS_IS_VIRTUAL(slot))
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+
+ index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+
+ index_close(indexDesc, NoLock);
+
+ return index_value;
+}
+
+/*
+ * tuple_table_slot_to_json_datum
+ *
+ * Helper function to convert a TupleTableSlot to Jsonb.
+ */
+static Datum
+tuple_table_slot_to_json_datum(TupleTableSlot *slot)
+{
+ HeapTuple tuple;
+ Datum datum;
+ Datum json;
+
+ Assert(slot != NULL);
+
+ tuple = ExecCopySlotHeapTuple(slot);
+ datum = heap_copy_tuple_as_datum(tuple, slot->tts_tupleDescriptor);
+
+ json = DirectFunctionCall1(row_to_json, datum);
+ heap_freetuple(tuple);
+
+ return json;
+}
+
+/*
+ * tuple_table_slot_to_indextup_json
+ *
+ * Fetch replica identity key from the tuple table slot and convert into a
+ * jsonb datum.
+ */
+static Datum
+tuple_table_slot_to_indextup_json(EState *estate, Relation localrel,
+ Oid indexid, TupleTableSlot *slot)
+{
+ Relation indexDesc;
+ Datum values[INDEX_MAX_KEYS];
+ bool isnull[INDEX_MAX_KEYS];
+ HeapTuple tuple;
+ TupleDesc tupdesc;
+ Datum datum;
+
+ Assert(slot != NULL);
+
+ Assert(CheckRelationOidLockedByMe(indexid, RowExclusiveLock, true));
+
+ indexDesc = index_open(indexid, NoLock);
+
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+ tupdesc = RelationGetDescr(indexDesc);
+
+ /* Bless the tupdesc so it can be looked up by row_to_json. */
+ BlessTupleDesc(tupdesc);
+
+ /* Form the replica identity tuple. */
+ tuple = heap_form_tuple(tupdesc, values, isnull);
+ datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ index_close(indexDesc, NoLock);
+
+ /* Convert to a JSONB datum. */
+ return DirectFunctionCall1(row_to_json, datum);
+}
+
+static TupleDesc
+build_conflict_tupledesc(void)
+{
+ TupleDesc tupdesc;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ for (int i = 0; i < MAX_LOCAL_CONFLICT_INFO_ATTRS; i++)
+ TupleDescInitEntry(tupdesc, (AttrNumber) (i + 1),
+ LocalConflictSchema[i].attname,
+ LocalConflictSchema[i].atttypid,
+ -1, 0);
+
+ BlessTupleDesc(tupdesc);
+
+ return tupdesc;
+}
+
+/*
+ * Builds the local conflicts JSONB array column from the list of
+ * ConflictTupleInfo objects.
+ *
+ * Example output structure:
+ * [ { "xid": "1001", "commit_ts": "...", "origin": "...", "tuple": {...} }, ... ]
+ */
+static Datum
+build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples)
+{
+ ListCell *lc;
+ List *json_datums = NIL; /* List to hold the row_to_json results (type json) */
+ Datum *json_datum_array;
+ bool *json_null_array;
+ Datum json_array_datum;
+ int num_conflicts;
+ int i;
+ int16 typlen;
+ bool typbyval;
+ char typalign;
+ TupleDesc tupdesc;
+
+ /* Build local conflicts tuple descriptor. */
+ tupdesc = build_conflict_tupledesc();
+
+ /* Process local conflict tuple list and prepare an array of JSON. */
+ foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
{
- tableslot = table_slot_create(localrel, &estate->es_tupleTable);
- tableslot = ExecCopySlot(tableslot, slot);
+ Datum values[MAX_LOCAL_CONFLICT_INFO_ATTRS] = {0};
+ bool nulls[MAX_LOCAL_CONFLICT_INFO_ATTRS] = {0};
+ char *origin_name = NULL;
+ HeapTuple tuple;
+ Datum json_datum;
+ int attno;
+
+ attno = 0;
+ values[attno++] = TransactionIdGetDatum(conflicttuple->xmin);
+
+ if (conflicttuple->ts)
+ values[attno++] = TimestampTzGetDatum(conflicttuple->ts);
+ else
+ nulls[attno++] = true;
+
+ if (conflicttuple->origin != InvalidRepOriginId)
+ replorigin_by_oid(conflicttuple->origin, true, &origin_name);
+
+ /* Store empty string if origin name for the tuple is NULL. */
+ if (origin_name != NULL)
+ values[attno++] = CStringGetTextDatum(origin_name);
+ else
+ nulls[attno++] = true;
+
+ /*
+ * Add the conflicting key values in the case of a unique constraint
+ * violation.
+ */
+ if (conflict_type == CT_INSERT_EXISTS ||
+ conflict_type == CT_UPDATE_EXISTS ||
+ conflict_type == CT_MULTIPLE_UNIQUE_CONFLICTS)
+ {
+ Oid indexoid = conflicttuple->indexoid;
+
+ Assert(OidIsValid(indexoid) && conflicttuple->slot &&
+ CheckRelationOidLockedByMe(indexoid, RowExclusiveLock,
+ true));
+ values[attno++] =
+ tuple_table_slot_to_indextup_json(estate, rel,
+ indexoid,
+ conflicttuple->slot);
+ }
+ else
+ nulls[attno++] = true;
+
+ /* Convert conflicting tuple to JSON datum. */
+ if (conflicttuple->slot)
+ values[attno] = tuple_table_slot_to_json_datum(conflicttuple->slot);
+ else
+ nulls[attno] = true;
+
+ Assert(attno + 1 == MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ tuple = heap_form_tuple(tupdesc, values, nulls);
+
+ json_datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ /*
+ * Build the higher level JSON datum in format described in function
+ * header.
+ */
+ json_datum = DirectFunctionCall1(row_to_json, json_datum);
+
+ /* Done with the temporary tuple. */
+ heap_freetuple(tuple);
+
+ /* Add to the array element. */
+ json_datums = lappend(json_datums, (void *) json_datum);
}
- /*
- * Initialize ecxt_scantuple for potential use in FormIndexDatum when
- * index expressions are present.
- */
- GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+ num_conflicts = list_length(json_datums);
- /*
- * The values/nulls arrays passed to BuildIndexValueDescription should be
- * the results of FormIndexDatum, which are the "raw" input to the index
- * AM.
- */
- FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values, isnull);
+ json_datum_array = (Datum *) palloc(num_conflicts * sizeof(Datum));
+ json_null_array = (bool *) palloc0(num_conflicts * sizeof(bool));
- index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+ i = 0;
+ foreach(lc, json_datums)
+ {
+ json_datum_array[i] = (Datum) lfirst(lc);
+ i++;
+ }
- index_close(indexDesc, NoLock);
+ /* Construct the json[] array Datum. */
+ get_typlenbyvalalign(JSONOID, &typlen, &typbyval, &typalign);
+ json_array_datum = PointerGetDatum(construct_array(json_datum_array,
+ num_conflicts,
+ JSONOID,
+ typlen,
+ typbyval,
+ typalign));
+ pfree(json_datum_array);
+ pfree(json_null_array);
+
+ return json_array_datum;
+}
- return index_value;
+/*
+ * prepare_conflict_log_tuple
+ *
+ * This routine prepares a tuple detailing a conflict encountered during
+ * logical replication. The prepared tuple will be stored in
+ * MyLogicalRepWorker->conflict_log_tuple which should be inserted into the
+ * conflict log table by calling InsertConflictLogTuple.
+ */
+static void
+prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot)
+{
+ Datum values[MAX_CONFLICT_ATTR_NUM] = {0};
+ bool nulls[MAX_CONFLICT_ATTR_NUM] = {0};
+ int attno;
+ char *remote_origin = NULL;
+ MemoryContext oldctx;
+
+ Assert(MyLogicalRepWorker->conflict_log_tuple == NULL);
+
+ /* Populate the values and nulls arrays. */
+ attno = 0;
+ values[attno++] = ObjectIdGetDatum(RelationGetRelid(rel));
+
+ values[attno++] =
+ CStringGetTextDatum(get_namespace_name(RelationGetNamespace(rel)));
+
+ values[attno++] = CStringGetTextDatum(RelationGetRelationName(rel));
+
+ values[attno++] = CStringGetTextDatum(ConflictTypeNames[conflict_type]);
+
+ if (TransactionIdIsValid(remote_xid))
+ values[attno++] = TransactionIdGetDatum(remote_xid);
+ else
+ nulls[attno++] = true;
+
+ values[attno++] = LSNGetDatum(remote_final_lsn);
+
+ if (remote_commit_ts > 0)
+ values[attno++] = TimestampTzGetDatum(remote_commit_ts);
+ else
+ nulls[attno++] = true;
+
+ if (replorigin_session_origin != InvalidRepOriginId)
+ replorigin_by_oid(replorigin_session_origin, true, &remote_origin);
+
+ if (remote_origin != NULL)
+ values[attno++] = CStringGetTextDatum(remote_origin);
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(searchslot))
+ {
+ Oid replica_index = GetRelationIdentityOrPK(rel);
+
+ /*
+ * If the table has a valid replica identity index, build the index
+ * json datum from key value. Otherwise, construct it from the complete
+ * tuple in REPLICA IDENTITY FULL cases.
+ */
+ if (OidIsValid(replica_index))
+ values[attno++] = tuple_table_slot_to_indextup_json(estate, rel,
+ replica_index,
+ searchslot);
+ else
+ values[attno++] = tuple_table_slot_to_json_datum(searchslot);
+ }
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(remoteslot))
+ values[attno++] = tuple_table_slot_to_json_datum(remoteslot);
+ else
+ nulls[attno++] = true;
+
+ values[attno] = build_local_conflicts_json_array(estate, rel,
+ conflict_type,
+ conflicttuples);
+
+ Assert(attno + 1 == MAX_CONFLICT_ATTR_NUM);
+
+ oldctx = MemoryContextSwitchTo(ApplyContext);
+ MyLogicalRepWorker->conflict_log_tuple =
+ heap_form_tuple(RelationGetDescr(conflictlogrel), values, nulls);
+ MemoryContextSwitchTo(oldctx);
}
diff --git a/src/backend/replication/logical/launcher.c b/src/backend/replication/logical/launcher.c
index 3991e1495d4..bc7e1d9ebde 100644
--- a/src/backend/replication/logical/launcher.c
+++ b/src/backend/replication/logical/launcher.c
@@ -477,6 +477,7 @@ retry:
worker->oldest_nonremovable_xid = retain_dead_tuples
? MyReplicationSlot->data.xmin
: InvalidTransactionId;
+ worker->conflict_log_tuple = NULL;
worker->last_lsn = InvalidXLogRecPtr;
TIMESTAMP_NOBEGIN(worker->last_send_time);
TIMESTAMP_NOBEGIN(worker->last_recv_time);
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index fc64476a9ef..05912a6050e 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -482,7 +482,9 @@ static bool MySubscriptionValid = false;
static List *on_commit_wakeup_workers_subids = NIL;
bool in_remote_transaction = false;
-static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+TransactionId remote_xid = InvalidTransactionId;
+TimestampTz remote_commit_ts = 0;
/* fields valid only when processing streamed transaction */
static bool in_streamed_transaction = false;
@@ -1219,6 +1221,8 @@ apply_handle_begin(StringInfo s)
set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
remote_final_lsn = begin_data.final_lsn;
+ remote_commit_ts = begin_data.committime;
+ remote_xid = begin_data.xid;
maybe_start_skipping_changes(begin_data.final_lsn);
@@ -1745,6 +1749,10 @@ apply_handle_stream_start(StringInfo s)
/* extract XID of the top-level transaction */
stream_xid = logicalrep_read_stream_start(s, &first_segment);
+ remote_xid = stream_xid;
+ remote_final_lsn = InvalidXLogRecPtr;
+ remote_commit_ts = 0;
+
if (!TransactionIdIsValid(stream_xid))
ereport(ERROR,
(errcode(ERRCODE_PROTOCOL_VIOLATION),
@@ -5609,6 +5617,28 @@ start_apply(XLogRecPtr origin_startpos)
pgstat_report_subscription_error(MySubscription->oid,
MyLogicalRepWorker->type);
+ /*
+ * Insert any pending conflict log tuple under a new transaction.
+ */
+ if (MyLogicalRepWorker->conflict_log_tuple != NULL)
+ {
+ Relation conflictlogrel;
+ ConflictLogDest dest;
+
+ StartTransactionCommand();
+ PushActiveSnapshot(GetTransactionSnapshot());
+
+ /* Open conflict log table and insert the tuple. */
+ conflictlogrel = GetConflictLogTableInfo(&dest);
+ if (ValidateConflictLogTable(conflictlogrel))
+ InsertConflictLogTuple(conflictlogrel);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+ table_close(conflictlogrel, RowExclusiveLock);
+
+ PopActiveSnapshot();
+ CommitTransactionCommand();
+ }
+
PG_RE_THROW();
}
}
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index 70f8744b381..5f313b7a976 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -126,9 +126,21 @@ static const ConflictLogColumnDef ConflictLogSchema[] =
{ .attname = "local_conflicts", .atttypid = JSONARRAYOID }
};
-/* Define the count using the array size */
#define MAX_CONFLICT_ATTR_NUM (sizeof(ConflictLogSchema) / sizeof(ConflictLogSchema[0]))
+/* Schema for the elements within the 'local_conflicts' JSON array */
+static const ConflictLogColumnDef LocalConflictSchema[] =
+{
+ { .attname = "xid", .atttypid = XIDOID },
+ { .attname = "commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "origin", .atttypid = TEXTOID },
+ { .attname = "key", .atttypid = JSONOID },
+ { .attname = "tuple", .atttypid = JSONOID }
+};
+
+#define MAX_LOCAL_CONFLICT_INFO_ATTRS \
+ (sizeof(LocalConflictSchema) / sizeof(LocalConflictSchema[0]))
+
extern bool GetTupleTransactionInfo(TupleTableSlot *localslot,
TransactionId *xmin,
RepOriginId *localorigin,
@@ -139,4 +151,7 @@ extern void ReportApplyConflict(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *remoteslot,
List *conflicttuples);
extern void InitConflictIndexes(ResultRelInfo *relInfo);
+extern Relation GetConflictLogTableInfo(ConflictLogDest *log_dest);
+extern void InsertConflictLogTuple(Relation conflictlogrel);
+extern bool ValidateConflictLogTable(Relation rel);
#endif
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index f081619f151..711c04c7297 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -101,6 +101,9 @@ typedef struct LogicalRepWorker
*/
TransactionId oldest_nonremovable_xid;
+ /* A conflict log tuple that is prepared but not yet inserted. */
+ HeapTuple conflict_log_tuple;
+
/* Stats. */
XLogRecPtr last_lsn;
TimestampTz last_send_time;
@@ -256,6 +259,10 @@ extern PGDLLIMPORT bool InitializingApplyWorker;
extern PGDLLIMPORT List *table_states_not_ready;
+extern XLogRecPtr remote_final_lsn;
+extern TimestampTz remote_commit_ts;
+extern TransactionId remote_xid;
+
extern void logicalrep_worker_attach(int slot);
extern LogicalRepWorker *logicalrep_worker_find(LogicalRepWorkerType wtype,
Oid subid, Oid relid,
diff --git a/src/test/subscription/t/037_conflict_dest.pl b/src/test/subscription/t/037_conflict_dest.pl
new file mode 100644
index 00000000000..a6f2a4d94bb
--- /dev/null
+++ b/src/test/subscription/t/037_conflict_dest.pl
@@ -0,0 +1,181 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+# Test conflicts in logical replication
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+###############################
+# Setup
+###############################
+
+# Create a publisher node
+my $node_publisher = PostgreSQL::Test::Cluster->new('publisher');
+$node_publisher->init(allows_streaming => 'logical');
+$node_publisher->start;
+
+# Create a subscriber node
+my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
+$node_subscriber->init(allows_streaming => 'logical');
+$node_subscriber->start;
+
+# Create a table on publisher
+$node_publisher->safe_psql('postgres',
+ "CREATE TABLE conf_tab (a int PRIMARY KEY, b int UNIQUE, c int UNIQUE);");
+
+$node_publisher->safe_psql('postgres',
+ "CREATE TABLE conf_tab_2 (a int PRIMARY KEY, b int UNIQUE, c int UNIQUE);"
+);
+
+# Create same table on subscriber
+$node_subscriber->safe_psql('postgres',
+ "CREATE TABLE conf_tab (a int PRIMARY key, b int UNIQUE, c int UNIQUE);");
+
+$node_subscriber->safe_psql(
+ 'postgres', qq[
+ CREATE TABLE conf_tab_2 (a int PRIMARY KEY, b int, c int, unique(a,b)) PARTITION BY RANGE (a);
+ CREATE TABLE conf_tab_2_p1 PARTITION OF conf_tab_2 FOR VALUES FROM (MINVALUE) TO (100);
+]);
+
+# Setup logical replication
+my $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
+$node_publisher->safe_psql('postgres',
+ "CREATE PUBLICATION pub_tab FOR TABLE conf_tab, conf_tab_2");
+
+# Create the subscription
+my $appname = 'sub_tab';
+$node_subscriber->safe_psql(
+ 'postgres',
+ "CREATE SUBSCRIPTION sub_tab
+ CONNECTION '$publisher_connstr application_name=$appname'
+ PUBLICATION pub_tab WITH (conflict_log_destination=table)");
+
+# Wait for initial table sync to finish
+$node_subscriber->wait_for_subscription_sync($node_publisher, $appname);
+
+##################################################
+# INSERT data on Pub and Sub
+##################################################
+
+# Insert data in the publisher table
+$node_publisher->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (1,1,1);");
+
+# Insert data in the subscriber table
+$node_subscriber->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (2,2,2), (3,3,3), (4,4,4);");
+
+###############################################################################
+# Test conflict insertion into the internal conflict log table
+###############################################################################
+
+$node_subscriber->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (10, 10, 10);");
+
+# Get the internally generated table name
+my $subid = $node_subscriber->safe_psql('postgres',
+ "SELECT oid FROM pg_subscription WHERE subname = 'sub_tab';");
+my $conflict_table = "conflict_log_table_$subid";
+
+$node_publisher->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (10, 20, 30);");
+
+# Wait for the conflict to be logged
+my $log_check = $node_subscriber->poll_query_until(
+ 'postgres',
+ "SELECT count(*) > 0 FROM $conflict_table;"
+);
+
+is($log_check, 1, 'Conflict was successfully logged to the internal table');
+
+my $json_query = qq[
+ SELECT string_agg((unnested.j::json)->'key'->>'a', ',')
+ FROM (
+ SELECT unnest(local_conflicts) AS j
+ FROM $conflict_table
+ ) AS unnested;
+];
+
+my $all_keys = $node_subscriber->safe_psql('postgres', $json_query);
+
+# Verify that '10' is present in the resulting string
+like($all_keys, qr/10/, 'Verified that key 10 exists in the local_conflicts log');
+
+pass('Conflict type and data successfully validated in internal table');
+
+# Final cleanup for subsequent bidirectional tests in the script
+$node_subscriber->safe_psql('postgres', "TRUNCATE conf_tab;");
+
+###############################################################################
+# Test Case: update_missing
+###############################################################################
+
+# Sync a row, then delete it locally on subscriber
+$node_publisher->safe_psql('postgres', "INSERT INTO conf_tab VALUES (50, 50, 50);");
+$node_publisher->wait_for_catchup($appname);
+$node_subscriber->safe_psql('postgres', "DELETE FROM conf_tab WHERE a = 50;");
+
+# Trigger conflict by updating that row on publisher
+$node_publisher->safe_psql('postgres', "UPDATE conf_tab SET b = 500 WHERE a = 50;");
+
+# Wait for the apply worker to detect the missing row and log it
+$node_subscriber->poll_query_until('postgres',
+ "SELECT count(*) > 0 FROM $conflict_table WHERE conflict_type = 'update_missing';"
+) or die "Timed out waiting for update_missing conflict";
+
+my $upd_miss_check = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM $conflict_table WHERE conflict_type = 'update_missing';");
+is($upd_miss_check, 1, 'Verified update_missing conflict logged to internal table');
+
+$node_subscriber->safe_psql('postgres', "TRUNCATE conf_tab;");
+
+###############################################################################
+# Test Case: insert_exists (via secondary unique index)
+###############################################################################
+
+# 1. Subscriber has a row with b=100
+$node_subscriber->safe_psql('postgres', "INSERT INTO conf_tab VALUES (100, 100, 100);");
+
+# 2. Publisher inserts a NEW PK (101) but a DUPLICATE 'b' (100)
+$node_publisher->safe_psql('postgres', "INSERT INTO conf_tab VALUES (101, 100, 101);");
+
+# 3. Verify it appears as 'insert_exists' in your log table
+$node_subscriber->poll_query_until('postgres',
+ "SELECT count(*) > 0 FROM $conflict_table WHERE conflict_type = 'insert_exists' AND local_conflicts::text LIKE '%100%';"
+) or die "Timed out waiting for secondary index insert_exists conflict";
+
+pass('Logged insert_exists triggered by secondary unique index violation');
+
+$node_subscriber->safe_psql('postgres', "TRUNCATE conf_tab;");
+
+###############################################################################
+# CASE 3: Switching Destination to 'log' (Server Log Verification)
+###############################################################################
+
+# Switch destination
+$node_subscriber->safe_psql('postgres',
+ "ALTER SUBSCRIPTION sub_tab SET (conflict_log_destination = 'all');");
+
+$node_subscriber->safe_psql('postgres', "TRUNCATE TABLE $conflict_table;");
+# Trigger a conflict for server log (insert_exists)
+$node_subscriber->safe_psql('postgres', "INSERT INTO conf_tab VALUES (600, 600, 600);");
+$node_publisher->safe_psql('postgres', "INSERT INTO conf_tab VALUES (600, 700, 700);");
+
+# Wait for table log
+$node_subscriber->poll_query_until('postgres', "SELECT count(*) > 0 FROM $conflict_table;")
+ or die "Timed out waiting for insert_exists conflict";
+
+# Check subscriber server log
+my $log_found = $node_subscriber->wait_for_log(
+ qr/conflict detected on relation "public.conf_tab": conflict=insert_exists/
+);
+ok($log_found, 'Conflict correctly directed to server stderr log');
+
+# Verify table count DID NOT increase for this conflict
+my $table_check = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM $conflict_table WHERE local_conflicts::text LIKE '%600%';");
+is($table_check, 1, 'Table log was bypassed when destination set to log');
+
+done_testing();
--
2.43.0
[text/x-patch] v15-0005-Preserve-conflict-log-destination-for-subscripti.patch (14.8K, ../../CALDaNm1zR1L2oq-LqYEcc8-wTZYjfJsiaTC_jQ8pGwbm0fv+3Q@mail.gmail.com/5-v15-0005-Preserve-conflict-log-destination-for-subscripti.patch)
download | inline diff:
From 0cccf05489d43cdf89cfd61a0561adb4e19163ec Mon Sep 17 00:00:00 2001
From: Vignesh C <vignesh21@gmail.com>
Date: Tue, 23 Dec 2025 11:12:47 +0530
Subject: [PATCH v15 5/5] Preserve conflict log destination for subscriptions
Support pg_dump to dump and restore the conflict_log_destination setting for
subscriptions.
During a normal CREATE SUBSCRIPTION, a conflict log table is created
automatically when required. However, during dump/restore or binary upgrade,
the conflict log table may already exist and must be reused rather than
recreated.
To ensure correct behavior, pg_dump now emits an ALTER SUBSCRIPTION command
after subscription creation to restore the conflict_log_destination setting.
While dumping, pg_dump temporarily sets the search_path to the schema in which
the conflict log table was created, ensuring that the conflict log table is
resolved with the appropriate schema.
---
src/backend/commands/subscriptioncmds.c | 174 +++++++++++++++++-------
src/bin/pg_dump/pg_dump.c | 59 +++++++-
src/bin/pg_dump/pg_dump.h | 2 +
src/bin/pg_dump/pg_dump_sort.c | 31 +++++
src/bin/pg_dump/t/002_pg_dump.pl | 7 +-
5 files changed, 220 insertions(+), 53 deletions(-)
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index b3694375191..1fbe0d474cf 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -1395,6 +1395,124 @@ CheckAlterSubOption(Subscription *sub, const char *option,
}
}
+/*
+ * AlterSubscriptionConflictLogDestination
+ *
+ * Update the conflict log table associated with a subscription when its
+ * conflict log destination is changed.
+ *
+ * If the new destination requires a conflict log table and none was previously
+ * required, this function validates an existing conflict log table identified
+ * by the subscription specific naming convention or creates a new one.
+ *
+ * If the new destination no longer requires a conflict log table, the existing
+ * conflict log table associated with the subscription is removed via internal
+ * dependency cleanup to prevent orphaned relations.
+ *
+ * The function enforces that any conflict log table used is a permanent
+ * relation in a permanent schema, matches the expected structure, and is not
+ * already associated with another subscription.
+ *
+ * On success, *conflicttablerelid is set to the OID of the conflict log table
+ * that was created or validated, or to InvalidOid if no table is required.
+ *
+ * Returns true if the subscription's conflict log table reference must be
+ * updated as a result of the destination change; false otherwise.
+ */
+static bool
+AlterSubscriptionConflictLogDestination(Subscription *sub,
+ ConflictLogDest logdest,
+ Oid *conflicttablerelid)
+{
+ ConflictLogDest old_dest = GetLogDestination(sub->logdestination);
+ bool want_table;
+ bool has_oldtable;
+ bool update_relid = false;
+ Oid relid = InvalidOid;
+
+ want_table = (logdest == CONFLICT_LOG_DEST_TABLE ||
+ logdest == CONFLICT_LOG_DEST_ALL);
+ has_oldtable = (old_dest == CONFLICT_LOG_DEST_TABLE ||
+ old_dest == CONFLICT_LOG_DEST_ALL);
+
+ if (want_table && !has_oldtable)
+ {
+ char relname[NAMEDATALEN];
+ Oid nspid;
+
+ GetConflictLogTableName(relname, sub->oid);
+ nspid = RangeVarGetCreationNamespace(makeRangeVar(NULL, relname, -1));
+ relid = get_relname_relid(relname, nspid);
+ if (OidIsValid(relid))
+ {
+ Relation conflictlogrel;
+ char *nspname = get_namespace_name(nspid);
+
+ /*
+ * Conflict log tables must be permanent relations. Disallow in
+ * temporary namespaces to ensure the same.
+ */
+ if (isTempNamespace(nspid))
+ ereport(ERROR,
+ errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot use conflict log table \"%s.%s\" of a temporary namespace",
+ nspname, relname),
+ errhint("Specify table from a permanent schema."));
+
+ conflictlogrel = table_open(relid, RowExclusiveLock);
+ if (conflictlogrel->rd_rel->relpersistence != RELPERSISTENCE_PERMANENT)
+ ereport(ERROR,
+ errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("conflict log table \"%s.%s\" must be a permanent table",
+ nspname, relname),
+ errhint("Specify a permanent table as the conflict log table."));
+
+ if (IsConflictLogTable(relid))
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("conflict log table \"%s.%s\" cannot be used",
+ nspname, relname),
+ errdetail("The specified table is already registered for a different subscription."),
+ errhint("Specify a different conflict log table."));
+ if (!ValidateConflictLogTable(conflictlogrel))
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("conflict log table \"%s.%s\" has an incompatible definition",
+ nspname, relname),
+ errdetail("The table does not match the required conflict log table structure."),
+ errhint("Create the conflict log table with the expected definition or specify a different table."));
+
+ table_close(conflictlogrel, NoLock);
+ }
+ else
+ relid = create_conflict_log_table(sub->oid, sub->name, nspid, relname);
+
+ update_relid = true;
+ }
+ else if (!want_table && has_oldtable)
+ {
+ ObjectAddress object;
+
+ /*
+ * Conflict log tables are recorded as internal dependencies of the
+ * subscription. Drop the table if it is not required anymore to
+ * avoid stale or orphaned relations.
+ *
+ * XXX: At present, only conflict log tables are managed this way. In
+ * future if we introduce additional internal dependencies, we may
+ * need a targeted deletion to avoid deletion of any other objects.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId, sub->oid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+ update_relid = true;
+ }
+
+ *conflicttablerelid = relid;
+ return update_relid;
+}
+
/*
* Alter the existing subscription.
*/
@@ -1738,65 +1856,23 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_LOG_DESTINATION))
{
ConflictLogDest old_dest =
- GetLogDestination(sub->logdestination);
+ GetLogDestination(sub->logdestination);
if (opts.logdest != old_dest)
{
- bool want_table =
- (opts.logdest == CONFLICT_LOG_DEST_TABLE ||
- opts.logdest == CONFLICT_LOG_DEST_ALL);
- bool has_oldtable =
- (old_dest == CONFLICT_LOG_DEST_TABLE ||
- old_dest == CONFLICT_LOG_DEST_ALL);
+ bool update_relid;
+ Oid relid = InvalidOid;
values[Anum_pg_subscription_sublogdestination - 1] =
CStringGetTextDatum(ConflictLogDestLabels[opts.logdest]);
replaces[Anum_pg_subscription_sublogdestination - 1] = true;
-
- if (want_table && !has_oldtable)
+ update_relid = AlterSubscriptionConflictLogDestination(sub, opts.logdest, &relid);
+ if (update_relid)
{
- char relname[NAMEDATALEN];
- Oid nspid;
- Oid relid;
-
- GetConflictLogTableName(relname, subid);
- nspid = RangeVarGetCreationNamespace(makeRangeVar(
- NULL, relname, -1));
-
- relid = create_conflict_log_table(subid, sub->name,
- nspid, relname);
-
- values[Anum_pg_subscription_subconflictlogrelid - 1] =
- ObjectIdGetDatum(relid);
- replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
- true;
- }
- else if (!want_table && has_oldtable)
- {
- ObjectAddress object;
-
- /*
- * Conflict log tables are recorded as internal
- * dependencies of the subscription. Drop the
- * table if it is not required anymore to avoid
- * stale or orphaned relations.
- *
- * XXX: At present, only conflict log tables are
- * managed this way. In future if we introduce
- * additional internal dependencies, we may need
- * a targeted deletion to avoid deletion of any
- * other objects.
- */
- ObjectAddressSet(object, SubscriptionRelationId,
- subid);
- performDeletion(&object, DROP_CASCADE,
- PERFORM_DELETION_INTERNAL |
- PERFORM_DELETION_SKIP_ORIGINAL);
-
values[Anum_pg_subscription_subconflictlogrelid - 1] =
- ObjectIdGetDatum(InvalidOid);
+ ObjectIdGetDatum(relid);
replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
- true;
+ true;
}
}
}
diff --git a/src/bin/pg_dump/pg_dump.c b/src/bin/pg_dump/pg_dump.c
index 27f6be3f0f8..d2477cfb5a1 100644
--- a/src/bin/pg_dump/pg_dump.c
+++ b/src/bin/pg_dump/pg_dump.c
@@ -5130,6 +5130,8 @@ getSubscriptions(Archive *fout)
int i_subfailover;
int i_subretaindeadtuples;
int i_submaxretention;
+ int i_subconflictlogrelid;
+ int i_sublogdestination;
int i,
ntups;
@@ -5216,10 +5218,17 @@ getSubscriptions(Archive *fout)
if (fout->remoteVersion >= 190000)
appendPQExpBufferStr(query,
- " s.submaxretention\n");
+ " s.submaxretention,\n");
else
appendPQExpBuffer(query,
- " 0 AS submaxretention\n");
+ " 0 AS submaxretention,\n");
+
+ if (fout->remoteVersion >= 190000)
+ appendPQExpBufferStr(query,
+ " s.subconflictlogrelid, s.sublogdestination\n");
+ else
+ appendPQExpBufferStr(query,
+ " NULL AS subconflictlogrelid, NULL AS sublogdestination\n");
appendPQExpBufferStr(query,
"FROM pg_subscription s\n");
@@ -5261,6 +5270,8 @@ getSubscriptions(Archive *fout)
i_subpublications = PQfnumber(res, "subpublications");
i_suborigin = PQfnumber(res, "suborigin");
i_suboriginremotelsn = PQfnumber(res, "suboriginremotelsn");
+ i_subconflictlogrelid = PQfnumber(res, "subconflictlogrelid");
+ i_sublogdestination = PQfnumber(res, "sublogdestination");
subinfo = pg_malloc(ntups * sizeof(SubscriptionInfo));
@@ -5309,6 +5320,33 @@ getSubscriptions(Archive *fout)
else
subinfo[i].suboriginremotelsn =
pg_strdup(PQgetvalue(res, i, i_suboriginremotelsn));
+ if (PQgetisnull(res, i, i_subconflictlogrelid))
+ subinfo[i].subconflictlogrelid = InvalidOid;
+ else
+ {
+ TableInfo *tableInfo;
+
+ subinfo[i].subconflictlogrelid =
+ atooid(PQgetvalue(res, i, i_subconflictlogrelid));
+
+ if (subinfo[i].subconflictlogrelid)
+ {
+ tableInfo = findTableByOid(subinfo[i].subconflictlogrelid);
+ if (!tableInfo)
+ pg_fatal("could not find conflict log table with OID %u",
+ subinfo[i].subconflictlogrelid);
+
+ addObjectDependency(&subinfo[i].dobj, tableInfo->dobj.dumpId);
+
+ if (!dopt->binary_upgrade)
+ tableInfo->dobj.dump = DUMP_COMPONENT_NONE;
+ }
+ }
+ if (PQgetisnull(res, i, i_sublogdestination))
+ subinfo[i].sublogdestination = NULL;
+ else
+ subinfo[i].sublogdestination =
+ pg_strdup(PQgetvalue(res, i, i_sublogdestination));
/* Decide whether we want to dump it */
selectDumpableObject(&(subinfo[i].dobj), fout);
@@ -5564,6 +5602,23 @@ dumpSubscription(Archive *fout, const SubscriptionInfo *subinfo)
appendPQExpBufferStr(query, ");\n");
+ if (subinfo->subconflictlogrelid)
+ {
+ TableInfo *tableInfo = findTableByOid(subinfo->subconflictlogrelid);
+
+ appendPQExpBuffer(query, "\n\nSELECT pg_catalog.set_config('search_path', '%s', false);\n",
+ tableInfo->dobj.namespace->dobj.name);
+ }
+
+ appendPQExpBuffer(query,
+ "\n\nALTER SUBSCRIPTION %s SET (conflict_log_destination = %s);\n",
+ qsubname,
+ subinfo->sublogdestination);
+
+
+ if (subinfo->subconflictlogrelid)
+ appendPQExpBufferStr(query, "\n\nSELECT pg_catalog.set_config('search_path', '', false);\n");
+
/*
* In binary-upgrade mode, we allow the replication to continue after the
* upgrade.
diff --git a/src/bin/pg_dump/pg_dump.h b/src/bin/pg_dump/pg_dump.h
index 72a00e1bc20..bd52c92140d 100644
--- a/src/bin/pg_dump/pg_dump.h
+++ b/src/bin/pg_dump/pg_dump.h
@@ -719,12 +719,14 @@ typedef struct _SubscriptionInfo
bool subfailover;
bool subretaindeadtuples;
int submaxretention;
+ Oid subconflictlogrelid;
char *subconninfo;
char *subslotname;
char *subsynccommit;
char *subpublications;
char *suborigin;
char *suboriginremotelsn;
+ char *sublogdestination;
} SubscriptionInfo;
/*
diff --git a/src/bin/pg_dump/pg_dump_sort.c b/src/bin/pg_dump/pg_dump_sort.c
index e2a4df4cf4b..2f170cae70f 100644
--- a/src/bin/pg_dump/pg_dump_sort.c
+++ b/src/bin/pg_dump/pg_dump_sort.c
@@ -1131,6 +1131,19 @@ repairTableAttrDefMultiLoop(DumpableObject *tableobj,
addObjectDependency(attrdefobj, tableobj->dumpId);
}
+/*
+ * Because we make subscriptions depend on their conflict log tables, while
+ * there is an automatic dependency in the other direction, we need to break
+ * the loop. Remove the automatic dependency, allowing the table to be created
+ * first.
+ */
+static void
+repairSubscriptionTableLoop(DumpableObject *subobj, DumpableObject *tableobj)
+{
+ /* Remove table's dependency on subscription */
+ removeObjectDependency(tableobj, subobj->dumpId);
+}
+
/*
* CHECK, NOT NULL constraints on domains work just like those on tables ...
*/
@@ -1361,6 +1374,24 @@ repairDependencyLoop(DumpableObject **loop,
return;
}
+ /*
+ * Subscription and its Conflict Log Table
+ */
+ if (nLoop == 2 &&
+ loop[0]->objType == DO_TABLE &&
+ loop[1]->objType == DO_SUBSCRIPTION)
+ {
+ repairSubscriptionTableLoop(loop[1], loop[0]);
+ return;
+ }
+ if (nLoop == 2 &&
+ loop[0]->objType == DO_SUBSCRIPTION &&
+ loop[1]->objType == DO_TABLE)
+ {
+ repairSubscriptionTableLoop(loop[0], loop[1]);
+ return;
+ }
+
/* index on partitioned table and corresponding index on partition */
if (nLoop == 2 &&
loop[0]->objType == DO_INDEX &&
diff --git a/src/bin/pg_dump/t/002_pg_dump.pl b/src/bin/pg_dump/t/002_pg_dump.pl
index e33aa95f6ff..8ec7b0069dd 100644
--- a/src/bin/pg_dump/t/002_pg_dump.pl
+++ b/src/bin/pg_dump/t/002_pg_dump.pl
@@ -3204,9 +3204,12 @@ my %tests = (
create_order => 50,
create_sql => 'CREATE SUBSCRIPTION sub3
CONNECTION \'dbname=doesnotexist\' PUBLICATION pub1
- WITH (connect = false, origin = any, streaming = on);',
+ WITH (connect = false, origin = any, streaming = on, conflict_log_destination= table);',
regexp => qr/^
- \QCREATE SUBSCRIPTION sub3 CONNECTION 'dbname=doesnotexist' PUBLICATION pub1 WITH (connect = false, slot_name = 'sub3', streaming = on);\E
+ \QCREATE SUBSCRIPTION sub3 CONNECTION 'dbname=doesnotexist' PUBLICATION pub1 WITH (connect = false, slot_name = 'sub3', streaming = on);\E\n\n\n
+ \QSELECT pg_catalog.set_config('search_path', 'public', false);\E\n\n\n
+ \QALTER SUBSCRIPTION sub3 SET (conflict_log_destination = table);\E\n\n\n
+ \QSELECT pg_catalog.set_config('search_path', '', false);\E
/xm,
like => { %full_runs, section_post_data => 1, },
unlike => {
--
2.43.0
[text/x-patch] v15-0004-Add-shared-index-for-conflict-log-table-lookup.patch (13.3K, ../../CALDaNm1zR1L2oq-LqYEcc8-wTZYjfJsiaTC_jQ8pGwbm0fv+3Q@mail.gmail.com/6-v15-0004-Add-shared-index-for-conflict-log-table-lookup.patch)
download | inline diff:
From adb17f448aafe908aa3501bd2388dd6c9230b72c Mon Sep 17 00:00:00 2001
From: Vignesh C <vignesh21@gmail.com>
Date: Sun, 21 Dec 2025 19:46:01 +0530
Subject: [PATCH v15 4/5] Add shared index for conflict log table lookup
Introduce a dedicated shared unique index on
pg_subscription.subconflictlogrelid to make conflict log table
detection efficient, and index-backed. Previously, IsConflictLogTable()
relied on a full catalog scan of pg_subscription, which was inefficient.
This change adds pg_subscription_conflictrel_index and marks it as a
shared index, matching the shared pg_subscription table, and rewrites
conflict log table detection to use an indexed systable scan.
---
src/backend/catalog/catalog.c | 1 +
src/backend/catalog/pg_publication.c | 12 +----------
src/backend/catalog/pg_subscription.c | 23 +++++++++++++++++++++
src/backend/commands/subscriptioncmds.c | 20 +++++++++++-------
src/backend/replication/logical/conflict.c | 4 +---
src/backend/replication/pgoutput/pgoutput.c | 15 +++++++++++---
src/bin/psql/describe.c | 4 +++-
src/include/catalog/pg_proc.dat | 7 +++++++
src/include/catalog/pg_subscription.h | 1 +
src/test/regress/expected/subscription.out | 7 ++++---
src/test/regress/sql/subscription.sql | 5 +++--
11 files changed, 69 insertions(+), 30 deletions(-)
diff --git a/src/backend/catalog/catalog.c b/src/backend/catalog/catalog.c
index 59caae8f1bc..b95a0a36cd0 100644
--- a/src/backend/catalog/catalog.c
+++ b/src/backend/catalog/catalog.c
@@ -336,6 +336,7 @@ IsSharedRelation(Oid relationId)
relationId == SharedSecLabelObjectIndexId ||
relationId == SubscriptionNameIndexId ||
relationId == SubscriptionObjectIndexId ||
+ relationId == SubscriptionConflictrelIndexId ||
relationId == TablespaceNameIndexId ||
relationId == TablespaceOidIndexId)
return true;
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index 9f84e02b7ef..187eb351f3f 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -86,15 +86,6 @@ check_publication_add_relation(Relation targetrel)
errmsg("cannot add relation \"%s\" to publication",
RelationGetRelationName(targetrel)),
errdetail("This operation is not supported for unlogged tables.")));
-
- /* Can't be conflict log table */
- if (IsConflictLogTable(RelationGetRelid(targetrel)))
- ereport(ERROR,
- (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
- errmsg("cannot add relation \"%s.%s\" to publication",
- get_namespace_name(RelationGetNamespace(targetrel)),
- RelationGetRelationName(targetrel)),
- errdetail("This operation is not supported for conflict log tables.")));
}
/*
@@ -188,8 +179,7 @@ pg_relation_is_publishable(PG_FUNCTION_ARGS)
PG_RETURN_NULL();
/* Subscription conflict log tables are not published */
- result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple)) &&
- !IsConflictLogTable(relid);
+ result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple));
ReleaseSysCache(tuple);
PG_RETURN_BOOL(result);
}
diff --git a/src/backend/catalog/pg_subscription.c b/src/backend/catalog/pg_subscription.c
index 27a9aee1c56..dae44c659f8 100644
--- a/src/backend/catalog/pg_subscription.c
+++ b/src/backend/catalog/pg_subscription.c
@@ -22,6 +22,7 @@
#include "catalog/pg_subscription.h"
#include "catalog/pg_subscription_rel.h"
#include "catalog/pg_type.h"
+#include "commands/subscriptioncmds.h"
#include "miscadmin.h"
#include "storage/lmgr.h"
#include "utils/array.h"
@@ -156,6 +157,28 @@ GetSubscription(Oid subid, bool missing_ok)
return sub;
}
+/*
+ * pg_relation_is_conflict_log_table
+ *
+ * Returns true if the given relation OID is used as a conflict log table
+ * by any subscription, else returns false.
+ */
+Datum
+pg_relation_is_conflict_log_table(PG_FUNCTION_ARGS)
+{
+ Oid relid = PG_GETARG_OID(0);
+ HeapTuple tuple;
+ bool result;
+
+ tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
+ if (!HeapTupleIsValid(tuple))
+ PG_RETURN_NULL();
+
+ result = IsConflictLogTable(relid);
+ ReleaseSysCache(tuple);
+ PG_RETURN_BOOL(result);
+}
+
/*
* Return number of subscriptions defined in given database.
* Used by dropdb() to check if database can indeed be dropped.
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index 05dcb5e3fe4..b3694375191 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -52,6 +52,7 @@
#include "storage/lmgr.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/guc.h"
#include "utils/lsyscache.h"
#include "utils/memutils.h"
@@ -3494,27 +3495,32 @@ bool
IsConflictLogTable(Oid relid)
{
Relation rel;
- TableScanDesc scan;
+ ScanKeyData scankey;
+ SysScanDesc scan;
HeapTuple tup;
bool is_clt = false;
rel = table_open(SubscriptionRelationId, AccessShareLock);
- scan = table_beginscan_catalog(rel, 0, NULL);
- while (HeapTupleIsValid(tup = heap_getnext(scan, ForwardScanDirection)))
+ ScanKeyInit(&scankey,
+ Anum_pg_subscription_subconflictlogrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(relid));
+
+ scan = systable_beginscan(rel, SubscriptionConflictrelIndexId,
+ true, NULL, 1, &scankey);
+ while (HeapTupleIsValid(tup = systable_getnext(scan)))
{
Form_pg_subscription subform = (Form_pg_subscription) GETSTRUCT(tup);
- /* Direct Oid comparison from catalog */
- if (OidIsValid(subform->subconflictlogrelid) &&
- subform->subconflictlogrelid == relid)
+ if (OidIsValid(subform->subconflictlogrelid))
{
is_clt = true;
break;
}
}
- table_endscan(scan);
+ systable_endscan(scan);
table_close(rel, AccessShareLock);
return is_clt;
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 7cc07a64427..0b6e3f4a2c8 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -294,13 +294,11 @@ GetConflictLogTableInfo(ConflictLogDest *log_dest)
void
InsertConflictLogTuple(Relation conflictlogrel)
{
- int options = HEAP_INSERT_NO_LOGICAL;
-
/* A valid tuple must be prepared and stored in MyLogicalRepWorker. */
Assert(MyLogicalRepWorker->conflict_log_tuple != NULL);
heap_insert(conflictlogrel, MyLogicalRepWorker->conflict_log_tuple,
- GetCurrentCommandId(true), options, NULL);
+ GetCurrentCommandId(true), 0, NULL);
/* Free conflict log tuple. */
heap_freetuple(MyLogicalRepWorker->conflict_log_tuple);
diff --git a/src/backend/replication/pgoutput/pgoutput.c b/src/backend/replication/pgoutput/pgoutput.c
index 787998abb8a..fe37b7fc284 100644
--- a/src/backend/replication/pgoutput/pgoutput.c
+++ b/src/backend/replication/pgoutput/pgoutput.c
@@ -131,6 +131,8 @@ typedef struct RelationSyncEntry
bool schema_sent;
+ bool conflictlogrel; /* is this relation used for conflict logging? */
+
/*
* This will be PUBLISH_GENCOLS_STORED if the relation contains generated
* columns and the 'publish_generated_columns' parameter is set to
@@ -2067,6 +2069,7 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
{
entry->replicate_valid = false;
entry->schema_sent = false;
+ entry->conflictlogrel = false;
entry->include_gencols_type = PUBLISH_GENCOLS_NONE;
entry->streamed_txns = NIL;
entry->pubactions.pubinsert = entry->pubactions.pubupdate =
@@ -2117,6 +2120,7 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
* earlier definition.
*/
entry->schema_sent = false;
+ entry->conflictlogrel = IsConflictLogTable(relid);
entry->include_gencols_type = PUBLISH_GENCOLS_NONE;
list_free(entry->streamed_txns);
entry->streamed_txns = NIL;
@@ -2199,7 +2203,7 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
* If this is a FOR ALL TABLES publication, pick the partition
* root and set the ancestor level accordingly.
*/
- if (pub->alltables)
+ if (pub->alltables && !entry->conflictlogrel)
{
publish = true;
if (pub->pubviaroot && am_partition)
@@ -2225,8 +2229,12 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
{
Oid ancestor;
int level;
- List *ancestors = get_partition_ancestors(relid);
+ List *ancestors;
+
+ /* Conflict log table cannot be a partition */
+ Assert(entry->conflictlogrel == false);
+ ancestors = get_partition_ancestors(relid);
ancestor = GetTopMostAncestorInPublication(pub->oid,
ancestors,
&level);
@@ -2243,7 +2251,8 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
}
if (list_member_oid(pubids, pub->oid) ||
- list_member_oid(schemaPubids, pub->oid) ||
+ (list_member_oid(schemaPubids, pub->oid) &&
+ !entry->conflictlogrel) ||
ancestor_published)
publish = true;
}
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index cc80f0f661c..2d612448241 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -3063,6 +3063,7 @@ describeOneTableDetails(const char *schemaname,
" JOIN pg_catalog.pg_publication_namespace pn ON p.oid = pn.pnpubid\n"
" JOIN pg_catalog.pg_class pc ON pc.relnamespace = pn.pnnspid\n"
"WHERE pc.oid ='%s' and pg_catalog.pg_relation_is_publishable('%s')\n"
+ "AND NOT pg_catalog.pg_relation_is_conflict_log_table('%s'::oid)\n"
"UNION\n"
"SELECT pubname\n"
" , pg_get_expr(pr.prqual, c.oid)\n"
@@ -3082,8 +3083,9 @@ describeOneTableDetails(const char *schemaname,
" , NULL\n"
"FROM pg_catalog.pg_publication p\n"
"WHERE p.puballtables AND pg_catalog.pg_relation_is_publishable('%s')\n"
+ "AND NOT pg_catalog.pg_relation_is_conflict_log_table('%s'::oid)\n"
"ORDER BY 1;",
- oid, oid, oid, oid);
+ oid, oid, oid, oid, oid, oid);
}
else
{
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index fd9448ec7b9..98fe8eee012 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12321,6 +12321,13 @@
prorettype => 'bool', proargtypes => 'regclass',
prosrc => 'pg_relation_is_publishable' },
+# subscriptions
+{ oid => '6123',
+ descr => 'returns whether a relation is a subscription conflict log table',
+ proname => 'pg_relation_is_conflict_log_table', provolatile => 's',
+ prorettype => 'bool', proargtypes => 'regclass',
+ prosrc => 'pg_relation_is_conflict_log_table' },
+
# rls
{ oid => '3298',
descr => 'row security for current context active on table by table oid',
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index 55f4bfa0419..46c446eaf8b 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -123,6 +123,7 @@ DECLARE_TOAST_WITH_MACRO(pg_subscription, 4183, 4184, PgSubscriptionToastTable,
DECLARE_UNIQUE_INDEX_PKEY(pg_subscription_oid_index, 6114, SubscriptionObjectIndexId, pg_subscription, btree(oid oid_ops));
DECLARE_UNIQUE_INDEX(pg_subscription_subname_index, 6115, SubscriptionNameIndexId, pg_subscription, btree(subdbid oid_ops, subname name_ops));
+DECLARE_UNIQUE_INDEX(pg_subscription_conflictrel_index, 6122, SubscriptionConflictrelIndexId, pg_subscription, btree(oid oid_ops, subconflictlogrelid oid_ops));
MAKE_SYSCACHE(SUBSCRIPTIONOID, pg_subscription_oid_index, 4);
MAKE_SYSCACHE(SUBSCRIPTIONNAME, pg_subscription_subname_index, 4);
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index 4ab58d90925..92423c83197 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -635,13 +635,14 @@ EXCEPTION WHEN dependent_objects_still_exist THEN
RAISE NOTICE 'captured expected error: dependent_objects_still_exist';
END $$;
NOTICE: captured expected error: dependent_objects_still_exist
--- PUBLICATION: Verify internal tables are not publishable
--- pg_relation_is_publishable should return false for internal conflict log tables
+-- PUBLICATION: Verify internal tables are publishable
+-- pg_relation_is_publishable should return true for internal conflict log
+-- tables, as it can be published using TABLE publication.
SELECT pg_relation_is_publishable(subconflictlogrelid)
FROM pg_subscription WHERE subname = 'regress_conflict_test1';
pg_relation_is_publishable
----------------------------
- f
+ t
(1 row)
-- CLEANUP: Proper drop reaps the table
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index 3359ff8be5c..b4b98c9a178 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -443,8 +443,9 @@ EXCEPTION WHEN dependent_objects_still_exist THEN
RAISE NOTICE 'captured expected error: dependent_objects_still_exist';
END $$;
--- PUBLICATION: Verify internal tables are not publishable
--- pg_relation_is_publishable should return false for internal conflict log tables
+-- PUBLICATION: Verify internal tables are publishable
+-- pg_relation_is_publishable should return true for internal conflict log
+-- tables, as it can be published using TABLE publication.
SELECT pg_relation_is_publishable(subconflictlogrelid)
FROM pg_subscription WHERE subname = 'regress_conflict_test1';
--
2.43.0
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-23 06:49 Peter Smith <smithpb2250@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: Peter Smith @ 2025-12-23 06:49 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Hi Dilip.
Here are some review comments after a first pass of patch v15-0001.
======
Commit Message
1.
If user choose to log into the table the table will automatically created while
creating the subscription with internal name i.e.
conflict_log_table_$subid$. The
table will be created in the current search path and table would be
automatically
dropped while dropping the subscription.
English:
/If user choose/
/the table the table/
/and table would/
======
src/backend/commands/subscriptioncmds.c
2.
+#define SUBOPT_CONFLICT_LOG_DESTINATION 0x00040000
For the values, you are using DEST instead of DESTINATION. You can do
the same here to keep the macro name a bit shorter.
~~~
parse_subscription_options:
3.
+ dest = GetLogDestination(val);
+
+ if (dest == CONFLICT_LOG_DEST_INVALID)
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("unrecognized conflict_log_destination value: \"%s\"", val),
+ errhint("Valid values are \"log\", \"table\", and \"all\".")));
I don't think CONFLICT_LOG_DEST_INVALID should even exist as an enum
value. Instead, the validation and the ereport(ERROR) should all be
done within GetLogDestination function. So, it should only return
valid values, else give an error.
~~~
CreateSubscription:
4.
+ /* Always set the destination, default will be log. */
+ values[Anum_pg_subscription_sublogdestination - 1] =
+ CStringGetTextDatum(ConflictLogDestLabels[opts.logdest]);
+
+ /*
+ * If the conflict log destination includes 'table', generate an internal
+ * name using the subscription OID and determine the target namespace based
+ * on the current search path. Store the namespace OID and the conflict log
+ * format in the pg_subscription catalog tuple., then physically create
+ * the table.
+ */
4a.
When referring to these parameter values, you should always
consistently quote them. Currently, there is a mix of lots of formats.
(e.g. log (unquoted), 'table' (single-quoted), "log" (double-quoted)).
Pick one style, and make them all the same. Check for the same everywhere.
~
4b.
Typo "tuple.,"
~~~
5.
+ if (opts.logdest == CONFLICT_LOG_DEST_TABLE ||
+ opts.logdest == CONFLICT_LOG_DEST_ALL)
IIUC, you are effectively treating these parameter values like bits
that can be OR-ed together. And if in the future a "list" is
supported, then that's exactly what you will be doing. So, IMO, they
should be defined that way. See a review comment later in this post.
e.g. this condition would be written more like:
if ((opts.logdest & CONFLICT_LOG_DEST_TABLE) != 0)
or, using the macro
if (IsSet(opts.logdest, CONFLICT_LOG_DEST_TABLE))
~~~
AlterSubscription:
6.
+ if (opts.logdest != old_dest)
+ {
+ bool want_table =
+ (opts.logdest == CONFLICT_LOG_DEST_TABLE ||
+ opts.logdest == CONFLICT_LOG_DEST_ALL);
+ bool has_oldtable =
+ (old_dest == CONFLICT_LOG_DEST_TABLE ||
+ old_dest == CONFLICT_LOG_DEST_ALL);
+
This is more of the same kind of logic that convinces me the code
should be using bitmasks.
SUGGESTION
bool want_table = IsSet(opts.logdest, CONFLICT_LOG_DEST_TABLE);
bool has_oldtable = IsSet(olddest, CONFLICT_LOG_DEST_TABLE);
~~~
create_conflict_log_table:
7.
+/*
+ * Create conflict log table.
+ *
+ * The subscription owner becomes the owner of this table and has all
+ * privileges on it.
+ */
+static Oid
+create_conflict_log_table(Oid subid, char *subname, Oid namespaceId,
+ char *conflictrel)
I felt something like 'relname' is a better name for the char *
conflictrel param. It clearly is the name of the conflict relation
because of the name of the function.
~~~
8.
+ /* Add a comments for the conflict log table. */
+ snprintf(comment, sizeof(comment),
+ "Conflict log table for subscription \"%s\"", subname);
+ CreateComments(relid, RelationRelationId, 0, comment);
+
8a.
typo /Add a comments/Add a comment/
~
8b.
My (previous review) suggestion for adding a table comment/description
made more sense when the CLT was some arbitrary name chosen by the
user. But, now that the CLT is a name like "conflict_log_table_%u",
the idea for a comment seems redundant.
~~~
9.
+/*
+ * Format the standardized internal conflict log table name for a subscription
+ *
+ * Use the OID to prevent collisions during rename operations.
+ */
+void
+GetConflictLogTableName(char *dest, Oid subid)
+{
+ snprintf(dest, NAMEDATALEN, "conflict_log_table_%u", subid);
+}
+
9a.
To emphasise that this is an "internal" table, IMO there should be a
"pg_" prefix for this table name.
~
9b.
Since it is internal anyway, why not make the tablename descriptive to
clarify what that number means?
e.g. "pg_conflict_log_table_for_subid_%u"
BTW, since it is already a TABLE, then why is "table" even part of
this name? Why not just "pg_conflict_log_for_subid_%u"
~~~
10.
+/*
+ * GetLogDestination
+ *
+ * Convert string to enum by comparing against standardized labels.
+ */
+ConflictLogDest
+GetLogDestination(const char *dest)
+{
+ /* Empty string or NULL defaults to LOG. */
+ if (dest == NULL || dest[0] == '\0')
+ return CONFLICT_LOG_DEST_LOG;
+
+ for (int i = CONFLICT_LOG_DEST_LOG; i <= CONFLICT_LOG_DEST_ALL; i++)
+ {
+ if (pg_strcasecmp(dest, ConflictLogDestLabels[i]) == 0)
+ return (ConflictLogDest) i;
+ }
+
+ /* Unrecognized string. */
+ return CONFLICT_LOG_DEST_INVALID;
+}
Mentioned previously: I think there should be no such thing as
CONFLICT_LOG_DEST_INVALID. I also think this function should be
responsible for the ereport(ERROR).
======
src/include/catalog/pg_subscription.h
11.
+ /*
+ * Strategy for logging replication conflicts:
+ * log - server log only,
+ * table - internal table only,
+ * all - both log and table.
+ */
+ text sublogdestination;
+
SUGGEST 'subconflictlogdest'
(see next review comment #12 for why)
~~~
12.
+ Oid conflictrelid; /* conflict log table Oid */
char *conninfo; /* Connection string to the publisher */
char *slotname; /* Name of the replication slot */
char *synccommit; /* Synchronous commit setting for worker */
List *publications; /* List of publication names to subscribe to */
char *origin; /* Only publish data originating from the
* specified origin */
+ char *logdestination; /* Conflict log destination */
} Subscription;
These don't seem very good member names:
Maybe 'conflictrelid' -> 'conflictlogrelid' (because it's rel of the
log; not the conflict)
Maybe 'logdestination' -> 'conflictlogdest' (because in future there
might be other kinds of subscription logs)
======
src/include/replication/conflict.h
13.
+typedef enum ConflictLogDest
+{
+ CONFLICT_LOG_DEST_INVALID = 0,
+ CONFLICT_LOG_DEST_LOG, /* "log" (default) */
+ CONFLICT_LOG_DEST_TABLE, /* "table" */
+ CONFLICT_LOG_DEST_ALL /* "all" */
+} ConflictLogDest;
+
I didn't like this enum much.
Suggest removing CONFLICT_LOG_DEST_INVALID.
And use bits for the other values.
And you can still have a default enum if you want.
SUGGESTION
typedef enum ConflictLogDest
{
CONFLICT_LOG_DEST_LOG = 0x001,
CONFLICT_LOG_DEST_TABLE = 0x010,
CONFLICT_LOG_DEST_DEFAULT = CONFLICT_LOG_DEST_LOG,
CONFLICT_LOG_DEST_ALL = CONFLICT_LOG_DEST_LOG | CONFLICT_LOG_DEST_TABLE,
} ConflictLogDest;
BTW, there are only a few values that the array won't exceed length
0x11, so I guess you can still keep your same designated initialiser
for the dest labels.
======
Kind Regards,
Peter Smith.
Fujitsu Australia
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-23 10:03 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: shveta malik @ 2025-12-23 10:03 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Mon, Dec 22, 2025 at 4:01 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> Done in V15
Thanks for the patches. A few comments on v15-002 for the part I have
reviewed so far:
1)
Defined twice:
+#define MAX_LOCAL_CONFLICT_INFO_ATTRS 5
+#define MAX_LOCAL_CONFLICT_INFO_ATTRS \
+ (sizeof(LocalConflictSchema) / sizeof(LocalConflictSchema[0]))
2)
GetConflictLogTableInfo:
+ *log_dest = GetLogDestination(MySubscription->logdestination);
+ conflictlogrelid = MySubscription->conflictrelid;
+
+ /* If destination is 'log' only, no table to open. */
+ if (*log_dest == CONFLICT_LOG_DEST_LOG)
+ return NULL;
We can get conflictlogrelid after the if-check for DEST_LOG.
3)
In ReportApplyConflict(), we form err_detail by calling
errdetail_apply_conflict(). But when dest is TABLE, we don't use
err_detail. Shall we skip creating it for dest=TABLE case?
4)
ReportApplyConflict():
+ /*
+ * Get both the conflict log destination and the opened conflict log
+ * relation for insertion.
+ */
+ conflictlogrel = GetConflictLogTableInfo(&dest);
+
We can move it after errdetail_apply_conflict(), closer to where we
actually use it.
5)
start_apply:
+ /* Open conflict log table and insert the tuple. */
+ conflictlogrel = GetConflictLogTableInfo(&dest);
+ if (ValidateConflictLogTable(conflictlogrel))
+ InsertConflictLogTuple(conflictlogrel);
We can have Assert here too before we call Validate:
Assert(dest == CONFLICT_LOG_DEST_TABLE || dest == CONFLICT_LOG_DEST_ALL);
6)
start_apply:
+ if (ValidateConflictLogTable(conflictlogrel))
+ InsertConflictLogTuple(conflictlogrel);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
InsertConflictLogTuple() already sets conflict_log_tuple to NULL.
Above is not needed.
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-23 11:48 Amit Kapila <amit.kapila16@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: Amit Kapila @ 2025-12-23 11:48 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Dilip Kumar <dilipbalaut@gmail.com>; vignesh C <vignesh21@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, Dec 23, 2025 at 10:55 AM shveta malik <shveta.malik@gmail.com> wrote:
>
> On Mon, Dec 22, 2025 at 9:11 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Mon, Dec 22, 2025 at 3:09 PM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > I think this needs more thought, others can be fixed.
> >
> > > 2)
> > > postgres=# drop schema shveta cascade;
> > > NOTICE: drop cascades to subscription sub1
> > > ERROR: global objects cannot be deleted by doDeletion
> > >
> > > Is this expected? Is the user supposed to see this error?
> > >
> > See below code, so this says if the object being dropped is the
> > outermost object (i.e. if we are dropping the table directly) then it
> > will disallow dropping the object on which it has INTERNAL DEPENDENCY,
> > OTOH if the object is being dropped via recursive drop (i.e. the table
> > is being dropped while dropping the schema) then object on which it
> > has INTERNAL dependency will also be added to the deletion list and
> > later will be dropped via doDeletion and later we are getting error as
> > subscription is a global object. I thought maybe we can handle an
> > additional case that the INTERNAL DEPENDENCY, is on subscription the
> > disallow dropping it irrespective of whether it is being called
> > directly or via recursive drop but then it will give an issue even
> > when we are trying to drop table during subscription drop, we can make
> > handle this case as well via 'flags' passed in findDependentObjects()
> > but need more investigation.
> >
> > Seeing this complexity makes me think more on is it really worth it to
> > maintain this dependency? Because during subscription drop we anyway
> > have to call performDeletion externally because this dependency is
> > local so we are just disallowing the conflict table drop, however the
> > ALTER table is allowed so what we are really protecting by protecting
> > the table drop, I think it can be just documented that if user try to
> > drop the table then conflict will not be inserted anymore?
> >
> > findDependentObjects()
> > {
> > ...
> > switch (foundDep->deptype)
> > {
> > ....
> > case DEPENDENCY_INTERNAL:
> > * 1. At the outermost recursion level, we must disallow the
> > * DROP. However, if the owning object is listed in
> > * pendingObjects, just release the caller's lock and return;
> > * we'll eventually complete the DROP when we reach that entry
> > * in the pending list.
> > }
> > }
> >
> > [1]
> > postgres[1333899]=# select * from pg_depend where objid > 16410;
> > classid | objid | objsubid | refclassid | refobjid | refobjsubid | deptype
> > ---------+-------+----------+------------+----------+-------------+---------
> > 1259 | 16420 | 0 | 2615 | 16410 | 0 | n
> > 1259 | 16420 | 0 | 6100 | 16419 | 0 | i
> > (4 rows)
> >
> > 16420 -> conflict_log_table_16419
> > 16419 -> subscription
> > 16410 -> schema s1
> >
>
> One approach could be to use something similar to
> PERFORM_DELETION_SKIP_EXTENSIONS in our case, but only for recursive
> drops. The effect would be that 'DROP SCHEMA ... CASCADE' would
> proceed without error, i.e., it would drop the tables as well without
> including the subscription in the dependency list. But if we try to
> drop a table directly (e.g., DROP TABLE CLT), it will still result in:
> ERROR: cannot drop table because subscription sub1 requires it
>
I think this way of allowing dropping the conflict table without
caring for the parent object (subscription) is not a good idea. How
about creating a dedicated schema, say pg_conflict for the purpose of
storing conflict tables? This will be similar to the pg_toast schema
for toast tables. So, similar to that each database will have a
pg_conflict schema. It prevents the "orphan" problem where a user
accidentally drops the logging schema but the Subscription is still
trying to write to it. pg_dump needs to ignore all system schemas
EXCEPT pg_conflict. This ensures the history is preserved during
migrations while still protecting the tables from accidental user
deletion. About permissions, I think we need to set the schema
permissions so that USAGE is public (so users can SELECT from their
logs) but CREATE is restricted to the superuser/subscription owner. We
may need to think some more about permissions.
I also tried to reason out if we can allow storing the conflict table
in pg_catalog but here are a few reasons why it won't be a good idea.
I think by default, pg_dump completely ignores the pg_catalog schema.
It assumes pg_catalog contains static system definitions (like
pg_class, pg_proc, etc.) that are re-generated by the initdb process,
not user data. If we place a conflict table in pg_catalog, it will not
be backed up. If a user runs pg_dump/all to migrate to a new server,
their subscription definition will survive, but their entire history
of conflict logs will vanish. Also from the permissions angle, If a
user wants to write a custom PL/pgSQL function to "retry" conflicts,
they might need to DELETE rows from the conflict table after fixing
them. Granting DELETE permissions on a table inside pg_catalog is
non-standard and often frowned upon by security auditors. It blurs the
line between "System Internals" (immutable) and "User Data" (mutable).
So, in short a separate pg_conflict schema appears to be a better solution.
Thoughts?
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-23 12:22 Dilip Kumar <dilipbalaut@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2025-12-23 12:22 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; vignesh C <vignesh21@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, Dec 23, 2025 at 5:18 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Tue, Dec 23, 2025 at 10:55 AM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > On Mon, Dec 22, 2025 at 9:11 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > On Mon, Dec 22, 2025 at 3:09 PM shveta malik <shveta.malik@gmail.com> wrote:
> > >
> > > I think this needs more thought, others can be fixed.
> > >
> > > > 2)
> > > > postgres=# drop schema shveta cascade;
> > > > NOTICE: drop cascades to subscription sub1
> > > > ERROR: global objects cannot be deleted by doDeletion
> > > >
> > > > Is this expected? Is the user supposed to see this error?
> > > >
> > > See below code, so this says if the object being dropped is the
> > > outermost object (i.e. if we are dropping the table directly) then it
> > > will disallow dropping the object on which it has INTERNAL DEPENDENCY,
> > > OTOH if the object is being dropped via recursive drop (i.e. the table
> > > is being dropped while dropping the schema) then object on which it
> > > has INTERNAL dependency will also be added to the deletion list and
> > > later will be dropped via doDeletion and later we are getting error as
> > > subscription is a global object. I thought maybe we can handle an
> > > additional case that the INTERNAL DEPENDENCY, is on subscription the
> > > disallow dropping it irrespective of whether it is being called
> > > directly or via recursive drop but then it will give an issue even
> > > when we are trying to drop table during subscription drop, we can make
> > > handle this case as well via 'flags' passed in findDependentObjects()
> > > but need more investigation.
> > >
> > > Seeing this complexity makes me think more on is it really worth it to
> > > maintain this dependency? Because during subscription drop we anyway
> > > have to call performDeletion externally because this dependency is
> > > local so we are just disallowing the conflict table drop, however the
> > > ALTER table is allowed so what we are really protecting by protecting
> > > the table drop, I think it can be just documented that if user try to
> > > drop the table then conflict will not be inserted anymore?
> > >
> > > findDependentObjects()
> > > {
> > > ...
> > > switch (foundDep->deptype)
> > > {
> > > ....
> > > case DEPENDENCY_INTERNAL:
> > > * 1. At the outermost recursion level, we must disallow the
> > > * DROP. However, if the owning object is listed in
> > > * pendingObjects, just release the caller's lock and return;
> > > * we'll eventually complete the DROP when we reach that entry
> > > * in the pending list.
> > > }
> > > }
> > >
> > > [1]
> > > postgres[1333899]=# select * from pg_depend where objid > 16410;
> > > classid | objid | objsubid | refclassid | refobjid | refobjsubid | deptype
> > > ---------+-------+----------+------------+----------+-------------+---------
> > > 1259 | 16420 | 0 | 2615 | 16410 | 0 | n
> > > 1259 | 16420 | 0 | 6100 | 16419 | 0 | i
> > > (4 rows)
> > >
> > > 16420 -> conflict_log_table_16419
> > > 16419 -> subscription
> > > 16410 -> schema s1
> > >
> >
> > One approach could be to use something similar to
> > PERFORM_DELETION_SKIP_EXTENSIONS in our case, but only for recursive
> > drops. The effect would be that 'DROP SCHEMA ... CASCADE' would
> > proceed without error, i.e., it would drop the tables as well without
> > including the subscription in the dependency list. But if we try to
> > drop a table directly (e.g., DROP TABLE CLT), it will still result in:
> > ERROR: cannot drop table because subscription sub1 requires it
> >
>
> I think this way of allowing dropping the conflict table without
> caring for the parent object (subscription) is not a good idea. How
> about creating a dedicated schema, say pg_conflict for the purpose of
> storing conflict tables? This will be similar to the pg_toast schema
> for toast tables. So, similar to that each database will have a
> pg_conflict schema. It prevents the "orphan" problem where a user
> accidentally drops the logging schema but the Subscription is still
> trying to write to it. pg_dump needs to ignore all system schemas
> EXCEPT pg_conflict. This ensures the history is preserved during
> migrations while still protecting the tables from accidental user
> deletion. About permissions, I think we need to set the schema
> permissions so that USAGE is public (so users can SELECT from their
> logs) but CREATE is restricted to the superuser/subscription owner. We
> may need to think some more about permissions.
>
> I also tried to reason out if we can allow storing the conflict table
> in pg_catalog but here are a few reasons why it won't be a good idea.
> I think by default, pg_dump completely ignores the pg_catalog schema.
> It assumes pg_catalog contains static system definitions (like
> pg_class, pg_proc, etc.) that are re-generated by the initdb process,
> not user data. If we place a conflict table in pg_catalog, it will not
> be backed up. If a user runs pg_dump/all to migrate to a new server,
> their subscription definition will survive, but their entire history
> of conflict logs will vanish. Also from the permissions angle, If a
> user wants to write a custom PL/pgSQL function to "retry" conflicts,
> they might need to DELETE rows from the conflict table after fixing
> them. Granting DELETE permissions on a table inside pg_catalog is
> non-standard and often frowned upon by security auditors. It blurs the
> line between "System Internals" (immutable) and "User Data" (mutable).
> So, in short a separate pg_conflict schema appears to be a better solution.
Yeah that makes sense. Although I haven't thought about all cases
whether it can be a problem anywhere, but meanwhile I tried
prototyping with this and it behaves what we want.
postgres[1651968]=# select * from pg_conflict.conflict_log_table_16406 ;
relid | schemaname | relname | conflict_type | remote_xid |
remote_commit_lsn | remote_commit_ts | remote_origin |
replica_identity | remote_tuple
|
local_conflicts
-------+------------+---------+-----------------------+------------+-------------------+-------------------------------+---------------+------------------+----------------
+------------------------------------------------------------------------------------------------------------------------------------
16385 | public | test | update_origin_differs | 761 |
0/01760BD8 | 2025-12-23 11:08:30.583816+00 | pg_16406 |
{"a":1} | {"a":1,"b":20}
| {"{\"xid\":\"772\",\"commit_ts\":\"2025-12-23T11:08:25.568561+00:00\",\"origin\":null,\"key\":null,\"tuple\":{\"a\":1,\"b\":10}}"}
(1 row)
-- Case1: Alter is not allowed
postgres[1651968]=# ALTER TABLE pg_conflict.conflict_log_table_16406
ADD COLUMN a int;
ERROR: 42501: permission denied: "conflict_log_table_16406" is a system catalog
LOCATION: RangeVarCallbackForAlterRelation, tablecmds.c:19634
-- Case2: drop is not allowed
postgres[1651968]=# drop table pg_conflict.conflict_log_table_16406;
ERROR: 42501: permission denied: "conflict_log_table_16406" is a system catalog
LOCATION: RangeVarCallbackForDropRelation, tablecmds.c:1803
--Case3: Drop subscription drops it internally
postgres[1651968]=# DROP SUBSCRIPTION sub ;
NOTICE: 00000: dropped replication slot "sub" on publisher
LOCATION: ReplicationSlotDropAtPubNode, subscriptioncmds.c:2470
DROP SUBSCRIPTION
postgres[1651968]=# \d pg_conflict.conflict_log_table_16406
Did not find any relation named "pg_conflict.conflict_log_table_16406".
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-24 07:41 Peter Smith <smithpb2250@gmail.com>
parent: Peter Smith <smithpb2250@gmail.com>
0 siblings, 0 replies; 912+ messages in thread
From: Peter Smith @ 2025-12-24 07:41 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, Dec 23, 2025 at 5:49 PM Peter Smith <smithpb2250@gmail.com> wrote:
>
> Hi Dilip.
>
> Here are some review comments after a first pass of patch v15-0001.
>
And, some more review comments for patch v15-0001.
======
src/backend/catalog/pg_subscription.c
1.
+ /* Always set the destination, default will be log. */
+ values[Anum_pg_subscription_sublogdestination - 1] =
+ CStringGetTextDatum(ConflictLogDestLabels[opts.logdest]);
+
None of the other values[] assignments here have a comment talking
about defaults, etc, so I don't think this needs one either.
======
src/backend/commands/subscriptioncmds.c
CreateSubscription:
2.
+ {
+ char conflict_table_name[NAMEDATALEN];
+ Oid namespaceId, logrelid;
In similar code in AlterSubscription, this was just called 'relname'.
Better to be consistent where possible. I think 'relname' would be
fine here too.
~~~
3.
+ else
+ {
+ /* Destination is "log"; no table is needed. */
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(InvalidOid);
+ }
I think it's better to say this using coded Asserts instead of just
assertions in comments.
e.g.
/* There is no conflict log table */
Assert(opts.logdest == CONFLICT_LOG_DEST_LOG)
values[...] = ObjectIdGetDatum(InvalidOid);
~~~
4.
+ if (isTempNamespace(namespaceId))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("could not generate conflict log table \"%s\"",
+ conflictrel),
+ errdetail("Conflict log tables cannot be created in a temporary namespace."),
+ errhint("Ensure your 'search_path' is set to permanent schema.")));
+
+ /* Report an error if the specified conflict log table already exists. */
+ if (OidIsValid(get_relname_relid(conflictrel, namespaceId)))
+ ereport(ERROR,
+ (errcode(ERRCODE_DUPLICATE_TABLE),
+ errmsg("could not generate conflict log table \"%s.%s\"",
+ get_namespace_name(namespaceId), conflictrel),
+ errdetail("A table with the internally generated name already exists."),
+ errhint("Drop the existing table or change your 'search_path' to use
a different schema.")));
I'm not sure about these messages:
4a.
"could not generate conflict log table".
- Why say "generate"?
- We don't need to say "conflict log table" -- that's already in the detail
SUGGESTION (something like)
"could not create relation \"%s\""
~
4b.
For the 2nd error, I think errmsg should look like below, same as any
other duplicate table error.
"relation \"%s.%s\" already exists"
~
4c.
+ errdetail("A table with the internally generated name already exists."),
I don't think this errdetail added anything useful. It already exists
-- that's all you need to know. Why does it matter that the name was
generated automatically?
~~~
GetLogDestination:
5.
+ for (int i = CONFLICT_LOG_DEST_LOG; i <= CONFLICT_LOG_DEST_ALL; i++)
+ {
+ if (pg_strcasecmp(dest, ConflictLogDestLabels[i]) == 0)
+ return (ConflictLogDest) i;
+ }
+
+ /* Unrecognized string. */
+ return CONFLICT_LOG_DEST_INVALID;
This code is making rash assumptions about the enums values being the
same as ordinals.
IMO it should be written like:
if (strcmp(dest, "log") == 0)
return CONFLICT_LOG_DEST_LOG;
if (strcmp(dest, "table") == 0)
return CONFLICT_LOG_DEST_TABLE;
if (strcmp(dest, "all") == 0)
return CONFLICT_LOG_DEST_ALL;
/* Unrecognized dest. */
ereport(ERROR, ...);
~~~
IsConflictLogTable
6.
+bool
+IsConflictLogTable(Oid relid)
+{
+ Relation rel;
If you enforce (as I've suggested elsewhere previously) a name
convention that the CLT must have "pg_" prefix, then perhaps you can
exit early from this function without having to scan all the OIDs,
just by checking first that the RelationGetRelationName(rel) must
start with "pg_".
======
src/test/regress/sql/subscription.sql
7.
+-- fail - unrecognized format value
/format/parameter/
~~
8.
Some of these tests are grouped together like
"ALTER: State transitions"
and
"Ensure drop table is not allowed, and DROP SUBSCRIPTION reaps the table"
etc.
These group boundaries should be identified more clearly with more
substantial comments.
e.g
#-- ==================================
#-- ALTER - state transition tests
#-- ==================================
~~~
9.
The "pg_relation_is_publishable" seems misplaced because it is buried
among the drop/reap tests. Maybe it should come before all that.
======
src/tools/pgindent/typedefs.list
10.
What about "typedef enum ConflictLogDest"
======
Kind Regards,
Peter Smith.
Fujitsu Australia
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-24 10:32 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: shveta malik @ 2025-12-24 10:32 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; vignesh C <vignesh21@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Tue, Dec 23, 2025 at 5:52 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Tue, Dec 23, 2025 at 5:18 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> >
> > On Tue, Dec 23, 2025 at 10:55 AM shveta malik <shveta.malik@gmail.com> wrote:
> > >
> > > On Mon, Dec 22, 2025 at 9:11 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > >
> > > > On Mon, Dec 22, 2025 at 3:09 PM shveta malik <shveta.malik@gmail.com> wrote:
> > > >
> > > > I think this needs more thought, others can be fixed.
> > > >
> > > > > 2)
> > > > > postgres=# drop schema shveta cascade;
> > > > > NOTICE: drop cascades to subscription sub1
> > > > > ERROR: global objects cannot be deleted by doDeletion
> > > > >
> > > > > Is this expected? Is the user supposed to see this error?
> > > > >
> > > > See below code, so this says if the object being dropped is the
> > > > outermost object (i.e. if we are dropping the table directly) then it
> > > > will disallow dropping the object on which it has INTERNAL DEPENDENCY,
> > > > OTOH if the object is being dropped via recursive drop (i.e. the table
> > > > is being dropped while dropping the schema) then object on which it
> > > > has INTERNAL dependency will also be added to the deletion list and
> > > > later will be dropped via doDeletion and later we are getting error as
> > > > subscription is a global object. I thought maybe we can handle an
> > > > additional case that the INTERNAL DEPENDENCY, is on subscription the
> > > > disallow dropping it irrespective of whether it is being called
> > > > directly or via recursive drop but then it will give an issue even
> > > > when we are trying to drop table during subscription drop, we can make
> > > > handle this case as well via 'flags' passed in findDependentObjects()
> > > > but need more investigation.
> > > >
> > > > Seeing this complexity makes me think more on is it really worth it to
> > > > maintain this dependency? Because during subscription drop we anyway
> > > > have to call performDeletion externally because this dependency is
> > > > local so we are just disallowing the conflict table drop, however the
> > > > ALTER table is allowed so what we are really protecting by protecting
> > > > the table drop, I think it can be just documented that if user try to
> > > > drop the table then conflict will not be inserted anymore?
> > > >
> > > > findDependentObjects()
> > > > {
> > > > ...
> > > > switch (foundDep->deptype)
> > > > {
> > > > ....
> > > > case DEPENDENCY_INTERNAL:
> > > > * 1. At the outermost recursion level, we must disallow the
> > > > * DROP. However, if the owning object is listed in
> > > > * pendingObjects, just release the caller's lock and return;
> > > > * we'll eventually complete the DROP when we reach that entry
> > > > * in the pending list.
> > > > }
> > > > }
> > > >
> > > > [1]
> > > > postgres[1333899]=# select * from pg_depend where objid > 16410;
> > > > classid | objid | objsubid | refclassid | refobjid | refobjsubid | deptype
> > > > ---------+-------+----------+------------+----------+-------------+---------
> > > > 1259 | 16420 | 0 | 2615 | 16410 | 0 | n
> > > > 1259 | 16420 | 0 | 6100 | 16419 | 0 | i
> > > > (4 rows)
> > > >
> > > > 16420 -> conflict_log_table_16419
> > > > 16419 -> subscription
> > > > 16410 -> schema s1
> > > >
> > >
> > > One approach could be to use something similar to
> > > PERFORM_DELETION_SKIP_EXTENSIONS in our case, but only for recursive
> > > drops. The effect would be that 'DROP SCHEMA ... CASCADE' would
> > > proceed without error, i.e., it would drop the tables as well without
> > > including the subscription in the dependency list. But if we try to
> > > drop a table directly (e.g., DROP TABLE CLT), it will still result in:
> > > ERROR: cannot drop table because subscription sub1 requires it
> > >
> >
> > I think this way of allowing dropping the conflict table without
> > caring for the parent object (subscription) is not a good idea. How
> > about creating a dedicated schema, say pg_conflict for the purpose of
> > storing conflict tables? This will be similar to the pg_toast schema
> > for toast tables. So, similar to that each database will have a
> > pg_conflict schema. It prevents the "orphan" problem where a user
> > accidentally drops the logging schema but the Subscription is still
> > trying to write to it. pg_dump needs to ignore all system schemas
> > EXCEPT pg_conflict. This ensures the history is preserved during
> > migrations while still protecting the tables from accidental user
> > deletion. About permissions, I think we need to set the schema
> > permissions so that USAGE is public (so users can SELECT from their
> > logs) but CREATE is restricted to the superuser/subscription owner. We
> > may need to think some more about permissions.
> >
> > I also tried to reason out if we can allow storing the conflict table
> > in pg_catalog but here are a few reasons why it won't be a good idea.
> > I think by default, pg_dump completely ignores the pg_catalog schema.
> > It assumes pg_catalog contains static system definitions (like
> > pg_class, pg_proc, etc.) that are re-generated by the initdb process,
> > not user data. If we place a conflict table in pg_catalog, it will not
> > be backed up. If a user runs pg_dump/all to migrate to a new server,
> > their subscription definition will survive, but their entire history
> > of conflict logs will vanish. Also from the permissions angle, If a
> > user wants to write a custom PL/pgSQL function to "retry" conflicts,
> > they might need to DELETE rows from the conflict table after fixing
> > them. Granting DELETE permissions on a table inside pg_catalog is
> > non-standard and often frowned upon by security auditors. It blurs the
> > line between "System Internals" (immutable) and "User Data" (mutable).
> > So, in short a separate pg_conflict schema appears to be a better solution.
>
> Yeah that makes sense. Although I haven't thought about all cases
> whether it can be a problem anywhere, but meanwhile I tried
> prototyping with this and it behaves what we want.
>
> postgres[1651968]=# select * from pg_conflict.conflict_log_table_16406 ;
> relid | schemaname | relname | conflict_type | remote_xid |
> remote_commit_lsn | remote_commit_ts | remote_origin |
> replica_identity | remote_tuple
> |
> local_conflicts
> -------+------------+---------+-----------------------+------------+-------------------+-------------------------------+---------------+------------------+----------------
> +------------------------------------------------------------------------------------------------------------------------------------
> 16385 | public | test | update_origin_differs | 761 |
> 0/01760BD8 | 2025-12-23 11:08:30.583816+00 | pg_16406 |
> {"a":1} | {"a":1,"b":20}
> | {"{\"xid\":\"772\",\"commit_ts\":\"2025-12-23T11:08:25.568561+00:00\",\"origin\":null,\"key\":null,\"tuple\":{\"a\":1,\"b\":10}}"}
> (1 row)
>
> -- Case1: Alter is not allowed
> postgres[1651968]=# ALTER TABLE pg_conflict.conflict_log_table_16406
> ADD COLUMN a int;
> ERROR: 42501: permission denied: "conflict_log_table_16406" is a system catalog
> LOCATION: RangeVarCallbackForAlterRelation, tablecmds.c:19634
>
How was this achieved? Did you modify IsSystemClass to behave
similarly to IsToastClass?
I tried to analyze whether there are alternative approaches. The
possible options I see are:
1)
heap_create_with_catalog() provides the boolean argument use_user_acl,
which is meant to apply user-defined default privileges. In theory, we
could predefine default ACLs for our schema and then invoke
heap_create_with_catalog() with use_user_acl = true. But it’s not
clear how to do this purely from internal code. We would need to mimic
or reuse the logic behind SetDefaultACLsInSchemas.
2)
Another option is to create the table using heap_create_with_catalog()
with use_user_acl = false, and then explicitly update pg_class.relacl
for that table, similar to what ExecGrant_Relation does when
processing GRANT/REVOKE. But I couldn’t find any existing internal
code paths (outside of the GRANT/REVOKE implementation itself) that do
this kind of post-creation ACL manipulation.
~~
So overall, I feel changing IsSystemClass is the simpler way right
now. To set ACL before/after/during heap_create_with_catalog is a
tricky thing, at-least I could not find an easier way to do this,
unless I have missed something.
Thoughts on possible approaches?
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-24 11:59 vignesh C <vignesh21@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
0 siblings, 0 replies; 912+ messages in thread
From: vignesh C @ 2025-12-24 11:59 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Dilip Kumar <dilipbalaut@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Fri, 19 Dec 2025 at 11:49, Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Fri, Dec 19, 2025 at 10:40 AM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > On Fri, Dec 19, 2025 at 9:53 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> >
> > > 2. Do we want to support multi destination then providing string like
> > > 'conflict_log_destination = 'log,table,..' make more sense but then we
> > > would have to store as a string in catalog and parse it everytime we
> > > insert conflicts or alter subscription OTOH currently I have just
> > > support single option log/table/both which make things much easy
> > > because then in catalog we can store as a single char field and don't
> > > need any parsing. And since the input are taken as a string itself,
> > > even if in future we want to support more options like 'log,table,..'
> > > it would be backward compatible with old options.
> >
> > I feel, combination of options might be a good idea, similar to how
> > 'log_destination' provides. But it can be done in future versions and
> > the first draft can be a simple one.
> >
>
> Considering the future extension of storing conflict information in
> multiple places, it would be good to follow log_destination. Yes, it
> is more work now but I feel that will be future-proof.
The attached patch has the changes to specify conflict_log_destination
with a combination of table, log and all. This is implemented in
v15-0006 patch, there is no change in other patched v15-0001 ...
v15-0005 patches which are the same as the patches attached from [1].
[1] - https://www.postgresql.org/message-id/CALDaNm1zR1L2oq-LqYEcc8-wTZYjfJsiaTC_jQ8pGwbm0fv%2B3Q%40mail.g...
Regards,
Vignesh
Attachments:
[application/octet-stream] v15-0002-Implement-the-conflict-insertion-infrastructure-.patch (34.3K, ../../CALDaNm3M+OUNs+HTDrKfOXGGPEaeYJg5vQQZNozJ4MvoJ073Zg@mail.gmail.com/2-v15-0002-Implement-the-conflict-insertion-infrastructure-.patch)
download | inline diff:
From a00a90a9f139b06b7d442b99e9de6e4b715fcd59 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Sat, 20 Dec 2025 15:20:09 +0530
Subject: [PATCH v15 2/6] Implement the conflict insertion infrastructure into
the conflict log table
Note: A single remote tuple may conflict with multiple local tuples when conflict type
is CT_MULTIPLE_UNIQUE_CONFLICTS, so for handling this case we create a single row in
conflict log table with respect to each remote conflict tuple even if it conflicts with
multiple local tuples and we store the multiple conflict tuples as a single JSON array
element in format as
[ { "xid": "1001", "commit_ts": "...", "origin": "...", "tuple": {...} }, ... ]
We can extract the elements of the local tuples from the conflict log table row
as given in below example.
SELECT remote_xid, relname, remote_origin, local_conflicts[1] ->> 'xid' AS local_xid,
local_conflicts[1] ->> 'tuple' AS local_tuple
FROM myschema.conflict_log_history2;
remote_xid | relname | remote_origin | local_xid | local_tuple
------------+----------+---------------+-----------+---------------------
760 | test | pg_16406 | 771 | {"a":1,"b":10}
765 | conf_tab | pg_16406 | 775 | {"a":2,"b":2,"c":2}
---
src/backend/replication/logical/conflict.c | 596 ++++++++++++++++++-
src/backend/replication/logical/launcher.c | 1 +
src/backend/replication/logical/worker.c | 32 +-
src/include/replication/conflict.h | 17 +-
src/include/replication/worker_internal.h | 7 +
src/test/subscription/t/037_conflict_dest.pl | 181 ++++++
6 files changed, 800 insertions(+), 34 deletions(-)
create mode 100644 src/test/subscription/t/037_conflict_dest.pl
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 16695592265..7cc07a64427 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -15,13 +15,22 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/tableam.h"
+#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "pgstat.h"
#include "replication/conflict.h"
#include "replication/worker_internal.h"
#include "storage/lmgr.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/lsyscache.h"
+#include "utils/pg_lsn.h"
+#include "utils/jsonb.h"
+
+#define MAX_LOCAL_CONFLICT_INFO_ATTRS 5
static const char *const ConflictTypeNames[] = {
[CT_INSERT_EXISTS] = "insert_exists",
@@ -50,8 +59,27 @@ static char *build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *localslot,
TupleTableSlot *remoteslot,
Oid indexoid);
+static void build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull);
static char *build_index_value_desc(EState *estate, Relation localrel,
TupleTableSlot *slot, Oid indexoid);
+static Datum tuple_table_slot_to_json_datum(TupleTableSlot *slot);
+static Datum tuple_table_slot_to_indextup_json(EState *estate,
+ Relation localrel,
+ Oid replica_index,
+ TupleTableSlot *slot);
+static TupleDesc build_conflict_tupledesc(void);
+static Datum build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples);
+static void prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot);
/*
* Get the xmin and commit timestamp data (origin and timestamp) associated
@@ -105,11 +133,19 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
ConflictType type, TupleTableSlot *searchslot,
TupleTableSlot *remoteslot, List *conflicttuples)
{
- Relation localrel = relinfo->ri_RelationDesc;
- StringInfoData err_detail;
+ Relation localrel = relinfo->ri_RelationDesc;
+ StringInfoData err_detail;
+ ConflictLogDest dest;
+ Relation conflictlogrel;
initStringInfo(&err_detail);
+ /*
+ * Get both the conflict log destination and the opened conflict log
+ * relation for insertion.
+ */
+ conflictlogrel = GetConflictLogTableInfo(&dest);
+
/* Form errdetail message by combining conflicting tuples information. */
foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
errdetail_apply_conflict(estate, relinfo, type, searchslot,
@@ -120,15 +156,63 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
conflicttuple->ts,
&err_detail);
+ /* Insert to table if destination is 'table' or 'all' */
+ if (conflictlogrel)
+ {
+ Assert(dest == CONFLICT_LOG_DEST_TABLE ||
+ dest == CONFLICT_LOG_DEST_ALL);
+
+ if (ValidateConflictLogTable(conflictlogrel))
+ {
+ /*
+ * Prepare the conflict log tuple. If the error level is below
+ * ERROR, insert it immediately. Otherwise, defer the insertion to
+ * a new transaction after the current one aborts, ensuring the
+ * insertion of the log tuple is not rolled back.
+ */
+ prepare_conflict_log_tuple(estate,
+ relinfo->ri_RelationDesc,
+ conflictlogrel,
+ type,
+ searchslot,
+ conflicttuples,
+ remoteslot);
+ if (elevel < ERROR)
+ InsertConflictLogTuple(conflictlogrel);
+ }
+
+ table_close(conflictlogrel, RowExclusiveLock);
+ }
+
pgstat_report_subscription_conflict(MySubscription->oid, type);
- ereport(elevel,
- errcode_apply_conflict(type),
- errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
- get_namespace_name(RelationGetNamespace(localrel)),
- RelationGetRelationName(localrel),
- ConflictTypeNames[type]),
- errdetail_internal("%s", err_detail.data));
+ /* Decide what detail to show in server logs. */
+ if (dest == CONFLICT_LOG_DEST_LOG || dest == CONFLICT_LOG_DEST_ALL)
+ {
+ /* Standard reporting with full internal details. */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail_internal("%s", err_detail.data));
+ }
+ else
+ {
+ /*
+ * 'table' only: Report the error msg but omit raw tuple data from
+ * server logs since it's already captured in the internal table.
+ */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail("Conflict details logged to internal table with OID %u.",
+ MySubscription->conflictrelid));
+ }
}
/*
@@ -162,6 +246,143 @@ InitConflictIndexes(ResultRelInfo *relInfo)
relInfo->ri_onConflictArbiterIndexes = uniqueIndexes;
}
+/*
+ * GetConflictLogTableInfo
+ *
+ * Fetches conflict logging metadata from the cached MySubscription pointer.
+ * Sets the destination enum in *log_dest and, if applicable, opens and
+ * returns the relation handle for the internal log table.
+ */
+Relation
+GetConflictLogTableInfo(ConflictLogDest *log_dest)
+{
+ Oid conflictlogrelid;
+ Relation conflictlogrel = NULL;
+
+ /*
+ * Convert the text log destination to the internal enum. MySubscription
+ * already contains the data from pg_subscription.
+ */
+ *log_dest = GetLogDestination(MySubscription->logdestination);
+ conflictlogrelid = MySubscription->conflictrelid;
+
+ /* If destination is 'log' only, no table to open. */
+ if (*log_dest == CONFLICT_LOG_DEST_LOG)
+ return NULL;
+
+ Assert(OidIsValid(conflictlogrelid));
+
+ conflictlogrel = table_open(conflictlogrelid, RowExclusiveLock);
+
+ /* Conflict log table is dropped or not accessible. */
+ if (conflictlogrel == NULL)
+ ereport(WARNING,
+ (errcode(ERRCODE_UNDEFINED_TABLE),
+ errmsg("conflict log table with OID %u does not exist",
+ conflictlogrelid)));
+
+ return conflictlogrel;
+}
+
+/*
+ * InsertConflictLogTuple
+ *
+ * Insert conflict log tuple into the conflict log table. It uses
+ * HEAP_INSERT_NO_LOGICAL to explicitly block logical decoding of the tuple
+ * inserted into the conflict log table.
+ */
+void
+InsertConflictLogTuple(Relation conflictlogrel)
+{
+ int options = HEAP_INSERT_NO_LOGICAL;
+
+ /* A valid tuple must be prepared and stored in MyLogicalRepWorker. */
+ Assert(MyLogicalRepWorker->conflict_log_tuple != NULL);
+
+ heap_insert(conflictlogrel, MyLogicalRepWorker->conflict_log_tuple,
+ GetCurrentCommandId(true), options, NULL);
+
+ /* Free conflict log tuple. */
+ heap_freetuple(MyLogicalRepWorker->conflict_log_tuple);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+}
+
+/*
+ * ValidateConflictLogTable - Validate conflict log table schema
+ *
+ * Checks whether the table definition including its column names, data
+ * types, and column ordering meet the requirements for conflict log
+ * table.
+ */
+bool
+ValidateConflictLogTable(Relation rel)
+{
+ Relation pg_attribute;
+ HeapTuple atup;
+ ScanKeyData scankey;
+ SysScanDesc scan;
+ int attcnt = 0;
+ bool tbl_ok = true;
+
+ pg_attribute = table_open(AttributeRelationId, AccessShareLock);
+ ScanKeyInit(&scankey,
+ Anum_pg_attribute_attrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationGetRelid(rel)));
+
+ scan = systable_beginscan(pg_attribute, AttributeRelidNumIndexId, true,
+ SnapshotSelf, 1, &scankey);
+
+ /* We only need to check up to MAX_CONFLICT_ATTR_NUM attributes */
+ while (HeapTupleIsValid(atup = systable_getnext(scan)))
+ {
+ const ConflictLogColumnDef *expected;
+ int schema_idx;
+ Form_pg_attribute attForm = (Form_pg_attribute) GETSTRUCT(atup);
+
+ /* Skip system columns and dropped columns */
+ if (attForm->attnum < 1 || attForm->attisdropped)
+ continue;
+
+ attcnt++;
+
+ /* attnum 1 corresponds to index 0 in ConflictLogSchema */
+ schema_idx = attForm->attnum - 1;
+
+ /* Check against the central schema definition */
+ if (schema_idx >= MAX_CONFLICT_ATTR_NUM)
+ {
+ /* Found an extra column beyond the required set */
+ tbl_ok = false;
+ break;
+ }
+
+ expected = &ConflictLogSchema[schema_idx];
+
+ if (attForm->atttypid != expected->atttypid ||
+ strcmp(NameStr(attForm->attname), expected->attname) != 0)
+ {
+ tbl_ok = false;
+ break;
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_attribute, AccessShareLock);
+
+ if (attcnt != MAX_CONFLICT_ATTR_NUM || !tbl_ok)
+ {
+ ereport(WARNING,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("conflict log table \"%s.%s\" structure changed, skipping insertion",
+ get_namespace_name(RelationGetNamespace(rel)),
+ RelationGetRelationName(rel)));
+ return false;
+ }
+
+ return true;
+}
+
/*
* Add SQLSTATE error code to the current conflict report.
*/
@@ -472,6 +693,40 @@ build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
return tuple_value.data;
}
+/*
+ * Helper function to extract the "raw" index key Datums and their null flags
+ * from a TupleTableSlot, given an already open index descriptor.
+ * This is the reusable core logic.
+ */
+static void
+build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull)
+{
+ TupleTableSlot *tableslot = slot;
+
+ /*
+ * If the slot is a virtual slot, copy it into a heap tuple slot as
+ * FormIndexDatum only works with heap tuple slots.
+ */
+ if (TTS_IS_VIRTUAL(slot))
+ {
+ /* Slot is created within the EState's tuple table */
+ tableslot = table_slot_create(localrel, &estate->es_tupleTable);
+ tableslot = ExecCopySlot(tableslot, slot);
+ }
+
+ /*
+ * Initialize ecxt_scantuple for potential use in FormIndexDatum
+ */
+ GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+
+ /* Form the index datums */
+ FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values,
+ isnull);
+}
+
/*
* Helper functions to construct a string describing the contents of an index
* entry. See BuildIndexValueDescription for details.
@@ -487,41 +742,318 @@ build_index_value_desc(EState *estate, Relation localrel, TupleTableSlot *slot,
Relation indexDesc;
Datum values[INDEX_MAX_KEYS];
bool isnull[INDEX_MAX_KEYS];
- TupleTableSlot *tableslot = slot;
- if (!tableslot)
+ if (!slot)
return NULL;
Assert(CheckRelationOidLockedByMe(indexoid, RowExclusiveLock, true));
indexDesc = index_open(indexoid, NoLock);
- /*
- * If the slot is a virtual slot, copy it into a heap tuple slot as
- * FormIndexDatum only works with heap tuple slots.
- */
- if (TTS_IS_VIRTUAL(slot))
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+
+ index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+
+ index_close(indexDesc, NoLock);
+
+ return index_value;
+}
+
+/*
+ * tuple_table_slot_to_json_datum
+ *
+ * Helper function to convert a TupleTableSlot to Jsonb.
+ */
+static Datum
+tuple_table_slot_to_json_datum(TupleTableSlot *slot)
+{
+ HeapTuple tuple;
+ Datum datum;
+ Datum json;
+
+ Assert(slot != NULL);
+
+ tuple = ExecCopySlotHeapTuple(slot);
+ datum = heap_copy_tuple_as_datum(tuple, slot->tts_tupleDescriptor);
+
+ json = DirectFunctionCall1(row_to_json, datum);
+ heap_freetuple(tuple);
+
+ return json;
+}
+
+/*
+ * tuple_table_slot_to_indextup_json
+ *
+ * Fetch replica identity key from the tuple table slot and convert into a
+ * jsonb datum.
+ */
+static Datum
+tuple_table_slot_to_indextup_json(EState *estate, Relation localrel,
+ Oid indexid, TupleTableSlot *slot)
+{
+ Relation indexDesc;
+ Datum values[INDEX_MAX_KEYS];
+ bool isnull[INDEX_MAX_KEYS];
+ HeapTuple tuple;
+ TupleDesc tupdesc;
+ Datum datum;
+
+ Assert(slot != NULL);
+
+ Assert(CheckRelationOidLockedByMe(indexid, RowExclusiveLock, true));
+
+ indexDesc = index_open(indexid, NoLock);
+
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+ tupdesc = RelationGetDescr(indexDesc);
+
+ /* Bless the tupdesc so it can be looked up by row_to_json. */
+ BlessTupleDesc(tupdesc);
+
+ /* Form the replica identity tuple. */
+ tuple = heap_form_tuple(tupdesc, values, isnull);
+ datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ index_close(indexDesc, NoLock);
+
+ /* Convert to a JSONB datum. */
+ return DirectFunctionCall1(row_to_json, datum);
+}
+
+static TupleDesc
+build_conflict_tupledesc(void)
+{
+ TupleDesc tupdesc;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ for (int i = 0; i < MAX_LOCAL_CONFLICT_INFO_ATTRS; i++)
+ TupleDescInitEntry(tupdesc, (AttrNumber) (i + 1),
+ LocalConflictSchema[i].attname,
+ LocalConflictSchema[i].atttypid,
+ -1, 0);
+
+ BlessTupleDesc(tupdesc);
+
+ return tupdesc;
+}
+
+/*
+ * Builds the local conflicts JSONB array column from the list of
+ * ConflictTupleInfo objects.
+ *
+ * Example output structure:
+ * [ { "xid": "1001", "commit_ts": "...", "origin": "...", "tuple": {...} }, ... ]
+ */
+static Datum
+build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples)
+{
+ ListCell *lc;
+ List *json_datums = NIL; /* List to hold the row_to_json results (type json) */
+ Datum *json_datum_array;
+ bool *json_null_array;
+ Datum json_array_datum;
+ int num_conflicts;
+ int i;
+ int16 typlen;
+ bool typbyval;
+ char typalign;
+ TupleDesc tupdesc;
+
+ /* Build local conflicts tuple descriptor. */
+ tupdesc = build_conflict_tupledesc();
+
+ /* Process local conflict tuple list and prepare an array of JSON. */
+ foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
{
- tableslot = table_slot_create(localrel, &estate->es_tupleTable);
- tableslot = ExecCopySlot(tableslot, slot);
+ Datum values[MAX_LOCAL_CONFLICT_INFO_ATTRS] = {0};
+ bool nulls[MAX_LOCAL_CONFLICT_INFO_ATTRS] = {0};
+ char *origin_name = NULL;
+ HeapTuple tuple;
+ Datum json_datum;
+ int attno;
+
+ attno = 0;
+ values[attno++] = TransactionIdGetDatum(conflicttuple->xmin);
+
+ if (conflicttuple->ts)
+ values[attno++] = TimestampTzGetDatum(conflicttuple->ts);
+ else
+ nulls[attno++] = true;
+
+ if (conflicttuple->origin != InvalidRepOriginId)
+ replorigin_by_oid(conflicttuple->origin, true, &origin_name);
+
+ /* Store empty string if origin name for the tuple is NULL. */
+ if (origin_name != NULL)
+ values[attno++] = CStringGetTextDatum(origin_name);
+ else
+ nulls[attno++] = true;
+
+ /*
+ * Add the conflicting key values in the case of a unique constraint
+ * violation.
+ */
+ if (conflict_type == CT_INSERT_EXISTS ||
+ conflict_type == CT_UPDATE_EXISTS ||
+ conflict_type == CT_MULTIPLE_UNIQUE_CONFLICTS)
+ {
+ Oid indexoid = conflicttuple->indexoid;
+
+ Assert(OidIsValid(indexoid) && conflicttuple->slot &&
+ CheckRelationOidLockedByMe(indexoid, RowExclusiveLock,
+ true));
+ values[attno++] =
+ tuple_table_slot_to_indextup_json(estate, rel,
+ indexoid,
+ conflicttuple->slot);
+ }
+ else
+ nulls[attno++] = true;
+
+ /* Convert conflicting tuple to JSON datum. */
+ if (conflicttuple->slot)
+ values[attno] = tuple_table_slot_to_json_datum(conflicttuple->slot);
+ else
+ nulls[attno] = true;
+
+ Assert(attno + 1 == MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ tuple = heap_form_tuple(tupdesc, values, nulls);
+
+ json_datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ /*
+ * Build the higher level JSON datum in format described in function
+ * header.
+ */
+ json_datum = DirectFunctionCall1(row_to_json, json_datum);
+
+ /* Done with the temporary tuple. */
+ heap_freetuple(tuple);
+
+ /* Add to the array element. */
+ json_datums = lappend(json_datums, (void *) json_datum);
}
- /*
- * Initialize ecxt_scantuple for potential use in FormIndexDatum when
- * index expressions are present.
- */
- GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+ num_conflicts = list_length(json_datums);
- /*
- * The values/nulls arrays passed to BuildIndexValueDescription should be
- * the results of FormIndexDatum, which are the "raw" input to the index
- * AM.
- */
- FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values, isnull);
+ json_datum_array = (Datum *) palloc(num_conflicts * sizeof(Datum));
+ json_null_array = (bool *) palloc0(num_conflicts * sizeof(bool));
- index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+ i = 0;
+ foreach(lc, json_datums)
+ {
+ json_datum_array[i] = (Datum) lfirst(lc);
+ i++;
+ }
- index_close(indexDesc, NoLock);
+ /* Construct the json[] array Datum. */
+ get_typlenbyvalalign(JSONOID, &typlen, &typbyval, &typalign);
+ json_array_datum = PointerGetDatum(construct_array(json_datum_array,
+ num_conflicts,
+ JSONOID,
+ typlen,
+ typbyval,
+ typalign));
+ pfree(json_datum_array);
+ pfree(json_null_array);
+
+ return json_array_datum;
+}
- return index_value;
+/*
+ * prepare_conflict_log_tuple
+ *
+ * This routine prepares a tuple detailing a conflict encountered during
+ * logical replication. The prepared tuple will be stored in
+ * MyLogicalRepWorker->conflict_log_tuple which should be inserted into the
+ * conflict log table by calling InsertConflictLogTuple.
+ */
+static void
+prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot)
+{
+ Datum values[MAX_CONFLICT_ATTR_NUM] = {0};
+ bool nulls[MAX_CONFLICT_ATTR_NUM] = {0};
+ int attno;
+ char *remote_origin = NULL;
+ MemoryContext oldctx;
+
+ Assert(MyLogicalRepWorker->conflict_log_tuple == NULL);
+
+ /* Populate the values and nulls arrays. */
+ attno = 0;
+ values[attno++] = ObjectIdGetDatum(RelationGetRelid(rel));
+
+ values[attno++] =
+ CStringGetTextDatum(get_namespace_name(RelationGetNamespace(rel)));
+
+ values[attno++] = CStringGetTextDatum(RelationGetRelationName(rel));
+
+ values[attno++] = CStringGetTextDatum(ConflictTypeNames[conflict_type]);
+
+ if (TransactionIdIsValid(remote_xid))
+ values[attno++] = TransactionIdGetDatum(remote_xid);
+ else
+ nulls[attno++] = true;
+
+ values[attno++] = LSNGetDatum(remote_final_lsn);
+
+ if (remote_commit_ts > 0)
+ values[attno++] = TimestampTzGetDatum(remote_commit_ts);
+ else
+ nulls[attno++] = true;
+
+ if (replorigin_session_origin != InvalidRepOriginId)
+ replorigin_by_oid(replorigin_session_origin, true, &remote_origin);
+
+ if (remote_origin != NULL)
+ values[attno++] = CStringGetTextDatum(remote_origin);
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(searchslot))
+ {
+ Oid replica_index = GetRelationIdentityOrPK(rel);
+
+ /*
+ * If the table has a valid replica identity index, build the index
+ * json datum from key value. Otherwise, construct it from the complete
+ * tuple in REPLICA IDENTITY FULL cases.
+ */
+ if (OidIsValid(replica_index))
+ values[attno++] = tuple_table_slot_to_indextup_json(estate, rel,
+ replica_index,
+ searchslot);
+ else
+ values[attno++] = tuple_table_slot_to_json_datum(searchslot);
+ }
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(remoteslot))
+ values[attno++] = tuple_table_slot_to_json_datum(remoteslot);
+ else
+ nulls[attno++] = true;
+
+ values[attno] = build_local_conflicts_json_array(estate, rel,
+ conflict_type,
+ conflicttuples);
+
+ Assert(attno + 1 == MAX_CONFLICT_ATTR_NUM);
+
+ oldctx = MemoryContextSwitchTo(ApplyContext);
+ MyLogicalRepWorker->conflict_log_tuple =
+ heap_form_tuple(RelationGetDescr(conflictlogrel), values, nulls);
+ MemoryContextSwitchTo(oldctx);
}
diff --git a/src/backend/replication/logical/launcher.c b/src/backend/replication/logical/launcher.c
index 3991e1495d4..bc7e1d9ebde 100644
--- a/src/backend/replication/logical/launcher.c
+++ b/src/backend/replication/logical/launcher.c
@@ -477,6 +477,7 @@ retry:
worker->oldest_nonremovable_xid = retain_dead_tuples
? MyReplicationSlot->data.xmin
: InvalidTransactionId;
+ worker->conflict_log_tuple = NULL;
worker->last_lsn = InvalidXLogRecPtr;
TIMESTAMP_NOBEGIN(worker->last_send_time);
TIMESTAMP_NOBEGIN(worker->last_recv_time);
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index fc64476a9ef..05912a6050e 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -482,7 +482,9 @@ static bool MySubscriptionValid = false;
static List *on_commit_wakeup_workers_subids = NIL;
bool in_remote_transaction = false;
-static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+TransactionId remote_xid = InvalidTransactionId;
+TimestampTz remote_commit_ts = 0;
/* fields valid only when processing streamed transaction */
static bool in_streamed_transaction = false;
@@ -1219,6 +1221,8 @@ apply_handle_begin(StringInfo s)
set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
remote_final_lsn = begin_data.final_lsn;
+ remote_commit_ts = begin_data.committime;
+ remote_xid = begin_data.xid;
maybe_start_skipping_changes(begin_data.final_lsn);
@@ -1745,6 +1749,10 @@ apply_handle_stream_start(StringInfo s)
/* extract XID of the top-level transaction */
stream_xid = logicalrep_read_stream_start(s, &first_segment);
+ remote_xid = stream_xid;
+ remote_final_lsn = InvalidXLogRecPtr;
+ remote_commit_ts = 0;
+
if (!TransactionIdIsValid(stream_xid))
ereport(ERROR,
(errcode(ERRCODE_PROTOCOL_VIOLATION),
@@ -5609,6 +5617,28 @@ start_apply(XLogRecPtr origin_startpos)
pgstat_report_subscription_error(MySubscription->oid,
MyLogicalRepWorker->type);
+ /*
+ * Insert any pending conflict log tuple under a new transaction.
+ */
+ if (MyLogicalRepWorker->conflict_log_tuple != NULL)
+ {
+ Relation conflictlogrel;
+ ConflictLogDest dest;
+
+ StartTransactionCommand();
+ PushActiveSnapshot(GetTransactionSnapshot());
+
+ /* Open conflict log table and insert the tuple. */
+ conflictlogrel = GetConflictLogTableInfo(&dest);
+ if (ValidateConflictLogTable(conflictlogrel))
+ InsertConflictLogTuple(conflictlogrel);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+ table_close(conflictlogrel, RowExclusiveLock);
+
+ PopActiveSnapshot();
+ CommitTransactionCommand();
+ }
+
PG_RE_THROW();
}
}
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index 70f8744b381..5f313b7a976 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -126,9 +126,21 @@ static const ConflictLogColumnDef ConflictLogSchema[] =
{ .attname = "local_conflicts", .atttypid = JSONARRAYOID }
};
-/* Define the count using the array size */
#define MAX_CONFLICT_ATTR_NUM (sizeof(ConflictLogSchema) / sizeof(ConflictLogSchema[0]))
+/* Schema for the elements within the 'local_conflicts' JSON array */
+static const ConflictLogColumnDef LocalConflictSchema[] =
+{
+ { .attname = "xid", .atttypid = XIDOID },
+ { .attname = "commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "origin", .atttypid = TEXTOID },
+ { .attname = "key", .atttypid = JSONOID },
+ { .attname = "tuple", .atttypid = JSONOID }
+};
+
+#define MAX_LOCAL_CONFLICT_INFO_ATTRS \
+ (sizeof(LocalConflictSchema) / sizeof(LocalConflictSchema[0]))
+
extern bool GetTupleTransactionInfo(TupleTableSlot *localslot,
TransactionId *xmin,
RepOriginId *localorigin,
@@ -139,4 +151,7 @@ extern void ReportApplyConflict(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *remoteslot,
List *conflicttuples);
extern void InitConflictIndexes(ResultRelInfo *relInfo);
+extern Relation GetConflictLogTableInfo(ConflictLogDest *log_dest);
+extern void InsertConflictLogTuple(Relation conflictlogrel);
+extern bool ValidateConflictLogTable(Relation rel);
#endif
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index f081619f151..711c04c7297 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -101,6 +101,9 @@ typedef struct LogicalRepWorker
*/
TransactionId oldest_nonremovable_xid;
+ /* A conflict log tuple that is prepared but not yet inserted. */
+ HeapTuple conflict_log_tuple;
+
/* Stats. */
XLogRecPtr last_lsn;
TimestampTz last_send_time;
@@ -256,6 +259,10 @@ extern PGDLLIMPORT bool InitializingApplyWorker;
extern PGDLLIMPORT List *table_states_not_ready;
+extern XLogRecPtr remote_final_lsn;
+extern TimestampTz remote_commit_ts;
+extern TransactionId remote_xid;
+
extern void logicalrep_worker_attach(int slot);
extern LogicalRepWorker *logicalrep_worker_find(LogicalRepWorkerType wtype,
Oid subid, Oid relid,
diff --git a/src/test/subscription/t/037_conflict_dest.pl b/src/test/subscription/t/037_conflict_dest.pl
new file mode 100644
index 00000000000..a6f2a4d94bb
--- /dev/null
+++ b/src/test/subscription/t/037_conflict_dest.pl
@@ -0,0 +1,181 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+# Test conflicts in logical replication
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+###############################
+# Setup
+###############################
+
+# Create a publisher node
+my $node_publisher = PostgreSQL::Test::Cluster->new('publisher');
+$node_publisher->init(allows_streaming => 'logical');
+$node_publisher->start;
+
+# Create a subscriber node
+my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
+$node_subscriber->init(allows_streaming => 'logical');
+$node_subscriber->start;
+
+# Create a table on publisher
+$node_publisher->safe_psql('postgres',
+ "CREATE TABLE conf_tab (a int PRIMARY KEY, b int UNIQUE, c int UNIQUE);");
+
+$node_publisher->safe_psql('postgres',
+ "CREATE TABLE conf_tab_2 (a int PRIMARY KEY, b int UNIQUE, c int UNIQUE);"
+);
+
+# Create same table on subscriber
+$node_subscriber->safe_psql('postgres',
+ "CREATE TABLE conf_tab (a int PRIMARY key, b int UNIQUE, c int UNIQUE);");
+
+$node_subscriber->safe_psql(
+ 'postgres', qq[
+ CREATE TABLE conf_tab_2 (a int PRIMARY KEY, b int, c int, unique(a,b)) PARTITION BY RANGE (a);
+ CREATE TABLE conf_tab_2_p1 PARTITION OF conf_tab_2 FOR VALUES FROM (MINVALUE) TO (100);
+]);
+
+# Setup logical replication
+my $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
+$node_publisher->safe_psql('postgres',
+ "CREATE PUBLICATION pub_tab FOR TABLE conf_tab, conf_tab_2");
+
+# Create the subscription
+my $appname = 'sub_tab';
+$node_subscriber->safe_psql(
+ 'postgres',
+ "CREATE SUBSCRIPTION sub_tab
+ CONNECTION '$publisher_connstr application_name=$appname'
+ PUBLICATION pub_tab WITH (conflict_log_destination=table)");
+
+# Wait for initial table sync to finish
+$node_subscriber->wait_for_subscription_sync($node_publisher, $appname);
+
+##################################################
+# INSERT data on Pub and Sub
+##################################################
+
+# Insert data in the publisher table
+$node_publisher->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (1,1,1);");
+
+# Insert data in the subscriber table
+$node_subscriber->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (2,2,2), (3,3,3), (4,4,4);");
+
+###############################################################################
+# Test conflict insertion into the internal conflict log table
+###############################################################################
+
+$node_subscriber->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (10, 10, 10);");
+
+# Get the internally generated table name
+my $subid = $node_subscriber->safe_psql('postgres',
+ "SELECT oid FROM pg_subscription WHERE subname = 'sub_tab';");
+my $conflict_table = "conflict_log_table_$subid";
+
+$node_publisher->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (10, 20, 30);");
+
+# Wait for the conflict to be logged
+my $log_check = $node_subscriber->poll_query_until(
+ 'postgres',
+ "SELECT count(*) > 0 FROM $conflict_table;"
+);
+
+is($log_check, 1, 'Conflict was successfully logged to the internal table');
+
+my $json_query = qq[
+ SELECT string_agg((unnested.j::json)->'key'->>'a', ',')
+ FROM (
+ SELECT unnest(local_conflicts) AS j
+ FROM $conflict_table
+ ) AS unnested;
+];
+
+my $all_keys = $node_subscriber->safe_psql('postgres', $json_query);
+
+# Verify that '10' is present in the resulting string
+like($all_keys, qr/10/, 'Verified that key 10 exists in the local_conflicts log');
+
+pass('Conflict type and data successfully validated in internal table');
+
+# Final cleanup for subsequent bidirectional tests in the script
+$node_subscriber->safe_psql('postgres', "TRUNCATE conf_tab;");
+
+###############################################################################
+# Test Case: update_missing
+###############################################################################
+
+# Sync a row, then delete it locally on subscriber
+$node_publisher->safe_psql('postgres', "INSERT INTO conf_tab VALUES (50, 50, 50);");
+$node_publisher->wait_for_catchup($appname);
+$node_subscriber->safe_psql('postgres', "DELETE FROM conf_tab WHERE a = 50;");
+
+# Trigger conflict by updating that row on publisher
+$node_publisher->safe_psql('postgres', "UPDATE conf_tab SET b = 500 WHERE a = 50;");
+
+# Wait for the apply worker to detect the missing row and log it
+$node_subscriber->poll_query_until('postgres',
+ "SELECT count(*) > 0 FROM $conflict_table WHERE conflict_type = 'update_missing';"
+) or die "Timed out waiting for update_missing conflict";
+
+my $upd_miss_check = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM $conflict_table WHERE conflict_type = 'update_missing';");
+is($upd_miss_check, 1, 'Verified update_missing conflict logged to internal table');
+
+$node_subscriber->safe_psql('postgres', "TRUNCATE conf_tab;");
+
+###############################################################################
+# Test Case: insert_exists (via secondary unique index)
+###############################################################################
+
+# 1. Subscriber has a row with b=100
+$node_subscriber->safe_psql('postgres', "INSERT INTO conf_tab VALUES (100, 100, 100);");
+
+# 2. Publisher inserts a NEW PK (101) but a DUPLICATE 'b' (100)
+$node_publisher->safe_psql('postgres', "INSERT INTO conf_tab VALUES (101, 100, 101);");
+
+# 3. Verify it appears as 'insert_exists' in your log table
+$node_subscriber->poll_query_until('postgres',
+ "SELECT count(*) > 0 FROM $conflict_table WHERE conflict_type = 'insert_exists' AND local_conflicts::text LIKE '%100%';"
+) or die "Timed out waiting for secondary index insert_exists conflict";
+
+pass('Logged insert_exists triggered by secondary unique index violation');
+
+$node_subscriber->safe_psql('postgres', "TRUNCATE conf_tab;");
+
+###############################################################################
+# CASE 3: Switching Destination to 'log' (Server Log Verification)
+###############################################################################
+
+# Switch destination
+$node_subscriber->safe_psql('postgres',
+ "ALTER SUBSCRIPTION sub_tab SET (conflict_log_destination = 'all');");
+
+$node_subscriber->safe_psql('postgres', "TRUNCATE TABLE $conflict_table;");
+# Trigger a conflict for server log (insert_exists)
+$node_subscriber->safe_psql('postgres', "INSERT INTO conf_tab VALUES (600, 600, 600);");
+$node_publisher->safe_psql('postgres', "INSERT INTO conf_tab VALUES (600, 700, 700);");
+
+# Wait for table log
+$node_subscriber->poll_query_until('postgres', "SELECT count(*) > 0 FROM $conflict_table;")
+ or die "Timed out waiting for insert_exists conflict";
+
+# Check subscriber server log
+my $log_found = $node_subscriber->wait_for_log(
+ qr/conflict detected on relation "public.conf_tab": conflict=insert_exists/
+);
+ok($log_found, 'Conflict correctly directed to server stderr log');
+
+# Verify table count DID NOT increase for this conflict
+my $table_check = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM $conflict_table WHERE local_conflicts::text LIKE '%600%';");
+is($table_check, 1, 'Table log was bypassed when destination set to log');
+
+done_testing();
--
2.43.0
[application/octet-stream] v15-0004-Add-shared-index-for-conflict-log-table-lookup.patch (13.3K, ../../CALDaNm3M+OUNs+HTDrKfOXGGPEaeYJg5vQQZNozJ4MvoJ073Zg@mail.gmail.com/3-v15-0004-Add-shared-index-for-conflict-log-table-lookup.patch)
download | inline diff:
From adb17f448aafe908aa3501bd2388dd6c9230b72c Mon Sep 17 00:00:00 2001
From: Vignesh C <vignesh21@gmail.com>
Date: Sun, 21 Dec 2025 19:46:01 +0530
Subject: [PATCH v15 4/6] Add shared index for conflict log table lookup
Introduce a dedicated shared unique index on
pg_subscription.subconflictlogrelid to make conflict log table
detection efficient, and index-backed. Previously, IsConflictLogTable()
relied on a full catalog scan of pg_subscription, which was inefficient.
This change adds pg_subscription_conflictrel_index and marks it as a
shared index, matching the shared pg_subscription table, and rewrites
conflict log table detection to use an indexed systable scan.
---
src/backend/catalog/catalog.c | 1 +
src/backend/catalog/pg_publication.c | 12 +----------
src/backend/catalog/pg_subscription.c | 23 +++++++++++++++++++++
src/backend/commands/subscriptioncmds.c | 20 +++++++++++-------
src/backend/replication/logical/conflict.c | 4 +---
src/backend/replication/pgoutput/pgoutput.c | 15 +++++++++++---
src/bin/psql/describe.c | 4 +++-
src/include/catalog/pg_proc.dat | 7 +++++++
src/include/catalog/pg_subscription.h | 1 +
src/test/regress/expected/subscription.out | 7 ++++---
src/test/regress/sql/subscription.sql | 5 +++--
11 files changed, 69 insertions(+), 30 deletions(-)
diff --git a/src/backend/catalog/catalog.c b/src/backend/catalog/catalog.c
index 59caae8f1bc..b95a0a36cd0 100644
--- a/src/backend/catalog/catalog.c
+++ b/src/backend/catalog/catalog.c
@@ -336,6 +336,7 @@ IsSharedRelation(Oid relationId)
relationId == SharedSecLabelObjectIndexId ||
relationId == SubscriptionNameIndexId ||
relationId == SubscriptionObjectIndexId ||
+ relationId == SubscriptionConflictrelIndexId ||
relationId == TablespaceNameIndexId ||
relationId == TablespaceOidIndexId)
return true;
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index 9f84e02b7ef..187eb351f3f 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -86,15 +86,6 @@ check_publication_add_relation(Relation targetrel)
errmsg("cannot add relation \"%s\" to publication",
RelationGetRelationName(targetrel)),
errdetail("This operation is not supported for unlogged tables.")));
-
- /* Can't be conflict log table */
- if (IsConflictLogTable(RelationGetRelid(targetrel)))
- ereport(ERROR,
- (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
- errmsg("cannot add relation \"%s.%s\" to publication",
- get_namespace_name(RelationGetNamespace(targetrel)),
- RelationGetRelationName(targetrel)),
- errdetail("This operation is not supported for conflict log tables.")));
}
/*
@@ -188,8 +179,7 @@ pg_relation_is_publishable(PG_FUNCTION_ARGS)
PG_RETURN_NULL();
/* Subscription conflict log tables are not published */
- result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple)) &&
- !IsConflictLogTable(relid);
+ result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple));
ReleaseSysCache(tuple);
PG_RETURN_BOOL(result);
}
diff --git a/src/backend/catalog/pg_subscription.c b/src/backend/catalog/pg_subscription.c
index 27a9aee1c56..dae44c659f8 100644
--- a/src/backend/catalog/pg_subscription.c
+++ b/src/backend/catalog/pg_subscription.c
@@ -22,6 +22,7 @@
#include "catalog/pg_subscription.h"
#include "catalog/pg_subscription_rel.h"
#include "catalog/pg_type.h"
+#include "commands/subscriptioncmds.h"
#include "miscadmin.h"
#include "storage/lmgr.h"
#include "utils/array.h"
@@ -156,6 +157,28 @@ GetSubscription(Oid subid, bool missing_ok)
return sub;
}
+/*
+ * pg_relation_is_conflict_log_table
+ *
+ * Returns true if the given relation OID is used as a conflict log table
+ * by any subscription, else returns false.
+ */
+Datum
+pg_relation_is_conflict_log_table(PG_FUNCTION_ARGS)
+{
+ Oid relid = PG_GETARG_OID(0);
+ HeapTuple tuple;
+ bool result;
+
+ tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
+ if (!HeapTupleIsValid(tuple))
+ PG_RETURN_NULL();
+
+ result = IsConflictLogTable(relid);
+ ReleaseSysCache(tuple);
+ PG_RETURN_BOOL(result);
+}
+
/*
* Return number of subscriptions defined in given database.
* Used by dropdb() to check if database can indeed be dropped.
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index 05dcb5e3fe4..b3694375191 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -52,6 +52,7 @@
#include "storage/lmgr.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/guc.h"
#include "utils/lsyscache.h"
#include "utils/memutils.h"
@@ -3494,27 +3495,32 @@ bool
IsConflictLogTable(Oid relid)
{
Relation rel;
- TableScanDesc scan;
+ ScanKeyData scankey;
+ SysScanDesc scan;
HeapTuple tup;
bool is_clt = false;
rel = table_open(SubscriptionRelationId, AccessShareLock);
- scan = table_beginscan_catalog(rel, 0, NULL);
- while (HeapTupleIsValid(tup = heap_getnext(scan, ForwardScanDirection)))
+ ScanKeyInit(&scankey,
+ Anum_pg_subscription_subconflictlogrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(relid));
+
+ scan = systable_beginscan(rel, SubscriptionConflictrelIndexId,
+ true, NULL, 1, &scankey);
+ while (HeapTupleIsValid(tup = systable_getnext(scan)))
{
Form_pg_subscription subform = (Form_pg_subscription) GETSTRUCT(tup);
- /* Direct Oid comparison from catalog */
- if (OidIsValid(subform->subconflictlogrelid) &&
- subform->subconflictlogrelid == relid)
+ if (OidIsValid(subform->subconflictlogrelid))
{
is_clt = true;
break;
}
}
- table_endscan(scan);
+ systable_endscan(scan);
table_close(rel, AccessShareLock);
return is_clt;
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 7cc07a64427..0b6e3f4a2c8 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -294,13 +294,11 @@ GetConflictLogTableInfo(ConflictLogDest *log_dest)
void
InsertConflictLogTuple(Relation conflictlogrel)
{
- int options = HEAP_INSERT_NO_LOGICAL;
-
/* A valid tuple must be prepared and stored in MyLogicalRepWorker. */
Assert(MyLogicalRepWorker->conflict_log_tuple != NULL);
heap_insert(conflictlogrel, MyLogicalRepWorker->conflict_log_tuple,
- GetCurrentCommandId(true), options, NULL);
+ GetCurrentCommandId(true), 0, NULL);
/* Free conflict log tuple. */
heap_freetuple(MyLogicalRepWorker->conflict_log_tuple);
diff --git a/src/backend/replication/pgoutput/pgoutput.c b/src/backend/replication/pgoutput/pgoutput.c
index 787998abb8a..fe37b7fc284 100644
--- a/src/backend/replication/pgoutput/pgoutput.c
+++ b/src/backend/replication/pgoutput/pgoutput.c
@@ -131,6 +131,8 @@ typedef struct RelationSyncEntry
bool schema_sent;
+ bool conflictlogrel; /* is this relation used for conflict logging? */
+
/*
* This will be PUBLISH_GENCOLS_STORED if the relation contains generated
* columns and the 'publish_generated_columns' parameter is set to
@@ -2067,6 +2069,7 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
{
entry->replicate_valid = false;
entry->schema_sent = false;
+ entry->conflictlogrel = false;
entry->include_gencols_type = PUBLISH_GENCOLS_NONE;
entry->streamed_txns = NIL;
entry->pubactions.pubinsert = entry->pubactions.pubupdate =
@@ -2117,6 +2120,7 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
* earlier definition.
*/
entry->schema_sent = false;
+ entry->conflictlogrel = IsConflictLogTable(relid);
entry->include_gencols_type = PUBLISH_GENCOLS_NONE;
list_free(entry->streamed_txns);
entry->streamed_txns = NIL;
@@ -2199,7 +2203,7 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
* If this is a FOR ALL TABLES publication, pick the partition
* root and set the ancestor level accordingly.
*/
- if (pub->alltables)
+ if (pub->alltables && !entry->conflictlogrel)
{
publish = true;
if (pub->pubviaroot && am_partition)
@@ -2225,8 +2229,12 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
{
Oid ancestor;
int level;
- List *ancestors = get_partition_ancestors(relid);
+ List *ancestors;
+
+ /* Conflict log table cannot be a partition */
+ Assert(entry->conflictlogrel == false);
+ ancestors = get_partition_ancestors(relid);
ancestor = GetTopMostAncestorInPublication(pub->oid,
ancestors,
&level);
@@ -2243,7 +2251,8 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
}
if (list_member_oid(pubids, pub->oid) ||
- list_member_oid(schemaPubids, pub->oid) ||
+ (list_member_oid(schemaPubids, pub->oid) &&
+ !entry->conflictlogrel) ||
ancestor_published)
publish = true;
}
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index cc80f0f661c..2d612448241 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -3063,6 +3063,7 @@ describeOneTableDetails(const char *schemaname,
" JOIN pg_catalog.pg_publication_namespace pn ON p.oid = pn.pnpubid\n"
" JOIN pg_catalog.pg_class pc ON pc.relnamespace = pn.pnnspid\n"
"WHERE pc.oid ='%s' and pg_catalog.pg_relation_is_publishable('%s')\n"
+ "AND NOT pg_catalog.pg_relation_is_conflict_log_table('%s'::oid)\n"
"UNION\n"
"SELECT pubname\n"
" , pg_get_expr(pr.prqual, c.oid)\n"
@@ -3082,8 +3083,9 @@ describeOneTableDetails(const char *schemaname,
" , NULL\n"
"FROM pg_catalog.pg_publication p\n"
"WHERE p.puballtables AND pg_catalog.pg_relation_is_publishable('%s')\n"
+ "AND NOT pg_catalog.pg_relation_is_conflict_log_table('%s'::oid)\n"
"ORDER BY 1;",
- oid, oid, oid, oid);
+ oid, oid, oid, oid, oid, oid);
}
else
{
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index fd9448ec7b9..98fe8eee012 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12321,6 +12321,13 @@
prorettype => 'bool', proargtypes => 'regclass',
prosrc => 'pg_relation_is_publishable' },
+# subscriptions
+{ oid => '6123',
+ descr => 'returns whether a relation is a subscription conflict log table',
+ proname => 'pg_relation_is_conflict_log_table', provolatile => 's',
+ prorettype => 'bool', proargtypes => 'regclass',
+ prosrc => 'pg_relation_is_conflict_log_table' },
+
# rls
{ oid => '3298',
descr => 'row security for current context active on table by table oid',
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index 55f4bfa0419..46c446eaf8b 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -123,6 +123,7 @@ DECLARE_TOAST_WITH_MACRO(pg_subscription, 4183, 4184, PgSubscriptionToastTable,
DECLARE_UNIQUE_INDEX_PKEY(pg_subscription_oid_index, 6114, SubscriptionObjectIndexId, pg_subscription, btree(oid oid_ops));
DECLARE_UNIQUE_INDEX(pg_subscription_subname_index, 6115, SubscriptionNameIndexId, pg_subscription, btree(subdbid oid_ops, subname name_ops));
+DECLARE_UNIQUE_INDEX(pg_subscription_conflictrel_index, 6122, SubscriptionConflictrelIndexId, pg_subscription, btree(oid oid_ops, subconflictlogrelid oid_ops));
MAKE_SYSCACHE(SUBSCRIPTIONOID, pg_subscription_oid_index, 4);
MAKE_SYSCACHE(SUBSCRIPTIONNAME, pg_subscription_subname_index, 4);
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index 4ab58d90925..92423c83197 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -635,13 +635,14 @@ EXCEPTION WHEN dependent_objects_still_exist THEN
RAISE NOTICE 'captured expected error: dependent_objects_still_exist';
END $$;
NOTICE: captured expected error: dependent_objects_still_exist
--- PUBLICATION: Verify internal tables are not publishable
--- pg_relation_is_publishable should return false for internal conflict log tables
+-- PUBLICATION: Verify internal tables are publishable
+-- pg_relation_is_publishable should return true for internal conflict log
+-- tables, as it can be published using TABLE publication.
SELECT pg_relation_is_publishable(subconflictlogrelid)
FROM pg_subscription WHERE subname = 'regress_conflict_test1';
pg_relation_is_publishable
----------------------------
- f
+ t
(1 row)
-- CLEANUP: Proper drop reaps the table
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index 3359ff8be5c..b4b98c9a178 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -443,8 +443,9 @@ EXCEPTION WHEN dependent_objects_still_exist THEN
RAISE NOTICE 'captured expected error: dependent_objects_still_exist';
END $$;
--- PUBLICATION: Verify internal tables are not publishable
--- pg_relation_is_publishable should return false for internal conflict log tables
+-- PUBLICATION: Verify internal tables are publishable
+-- pg_relation_is_publishable should return true for internal conflict log
+-- tables, as it can be published using TABLE publication.
SELECT pg_relation_is_publishable(subconflictlogrelid)
FROM pg_subscription WHERE subname = 'regress_conflict_test1';
--
2.43.0
[application/octet-stream] v15-0003-Doccumentation-patch.patch (11.3K, ../../CALDaNm3M+OUNs+HTDrKfOXGGPEaeYJg5vQQZNozJ4MvoJ073Zg@mail.gmail.com/4-v15-0003-Doccumentation-patch.patch)
download | inline diff:
From d230616135c4d6313d3e9bd72d5d3cd2b5362433 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Sun, 21 Dec 2025 18:51:57 +0530
Subject: [PATCH v15 3/6] Doccumentation patch
---
doc/src/sgml/logical-replication.sgml | 122 +++++++++++++++++++++-
doc/src/sgml/ref/alter_subscription.sgml | 12 ++-
doc/src/sgml/ref/create_subscription.sgml | 36 +++++++
3 files changed, 164 insertions(+), 6 deletions(-)
diff --git a/doc/src/sgml/logical-replication.sgml b/doc/src/sgml/logical-replication.sgml
index b3faaa675ef..5544f9beb02 100644
--- a/doc/src/sgml/logical-replication.sgml
+++ b/doc/src/sgml/logical-replication.sgml
@@ -253,7 +253,11 @@
The subscription is added using <link linkend="sql-createsubscription"><command>CREATE SUBSCRIPTION</command></link> and
can be stopped/resumed at any time using the
<link linkend="sql-altersubscription"><command>ALTER SUBSCRIPTION</command></link> command and removed using
- <link linkend="sql-dropsubscription"><command>DROP SUBSCRIPTION</command></link>.
+ <link linkend="sql-dropsubscription"><command>DROP SUBSCRIPTION</command></link>. When the
+ <literal>conflict_log_destination</literal> parameter is set to <literal>table</literal>
+ or <literal>all</literal>, the system automatically manages a dedicated conflict
+ storage table. This table is dropped automatically when the subscription is
+ removed via <command>DROP SUBSCRIPTION</command>.
</para>
<para>
@@ -289,6 +293,16 @@
option of <command>CREATE SUBSCRIPTION</command> for details.
</para>
+ <para>
+ Conflicts that occur during replication are typically logged as plain text
+ in the server log, which can be difficult for automated monitoring and
+ analysis. The <command>CREATE SUBSCRIPTION</command> command provides the
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination">
+ <literal>conflict_log_destination</literal></link> option to record detailed
+ conflict information in a structured, queryable format, significantly
+ improving post-mortem analysis and operational visibility of the replication
+ setup by logging to a system-managed table.
+
<sect2 id="logical-replication-subscription-slot">
<title>Logical Replication Slot Management</title>
@@ -2006,9 +2020,14 @@ Publications:
</para>
<para>
- Additional logging is triggered, and the conflict statistics are collected (displayed in the
- <link linkend="monitoring-pg-stat-subscription-stats"><structname>pg_stat_subscription_stats</structname></link> view)
- in the following <firstterm>conflict</firstterm> cases:
+ Additional logging is triggered, and the conflict statistics are collected
+ (displayed in the <link linkend="monitoring-pg-stat-subscription-stats">
+ <structname>pg_stat_subscription_stats</structname></link> view)
+ in the following <firstterm>conflict</firstterm> cases. If the subscription
+ was created with the <literal>conflict_log_destination</literal> set to
+ <literal>table</literal>, detailed conflict information is also inserted
+ into an internally managed table named <literal>conflict_log_table_<subscription_oid></literal>,
+ providing a structured record of all conflicts.
<variablelist>
<varlistentry id="conflict-insert-exists" xreflabel="insert_exists">
<term><literal>insert_exists</literal></term>
@@ -2117,6 +2136,92 @@ Publications:
log.
</para>
+ <para>
+ When the <literal>conflict_log_destination</literal> is set to
+ <literal>table</literal>, the system automatically creates a new table with
+ a predefined schema to log conflict details. This table is created in the
+ owner's schema, is owned by the subscription owner, and logs system fields.
+ The schema of this table is detailed in
+ <xref linkend="logical-replication-conflict-log-schema"/>.
+ </para>
+
+ <table id="logical-replication-conflict-log-schema">
+ <title>Conflict Log History Table Schema</title>
+ <tgroup cols="3">
+ <thead>
+ <row>
+ <entry>Column</entry>
+ <entry>Type</entry>
+ <entry>Description</entry>
+ </row>
+ </thead>
+ <tbody>
+ <row>
+ <entry><literal>relid</literal></entry>
+ <entry><type>oid</type></entry>
+ <entry>The OID of the local table where the conflict occurred.</entry>
+ </row>
+ <row>
+ <entry><literal>schemaname</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The schema name of the conflicting table.</entry>
+ </row>
+ <row>
+ <entry><literal>relname</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The name of the conflicting table.</entry>
+ </row>
+ <row>
+ <entry><literal>conflict_type</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The type of conflict that occurred (e.g., <literal>insert_exists</literal>).</entry>
+ </row>
+ <row>
+ <entry><literal>remote_xid</literal></entry>
+ <entry><type>xid</type></entry>
+ <entry>The remote transaction ID that caused the conflict.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_commit_lsn</literal></entry>
+ <entry><type>pg_lsn</type></entry>
+ <entry>The final LSN of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_commit_ts</literal></entry>
+ <entry><type>timestamptz</type></entry>
+ <entry>The remote commit timestamp of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_origin</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The origin of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_tuple</literal></entry>
+ <entry><type>json</type></entry>
+ <entry>The JSON representation of the incoming remote row that caused the conflict.</entry>
+ </row>
+ <row>
+ <entry><literal>local_conflicts</literal></entry>
+ <entry><type>json[]</type></entry>
+ <entry>
+ An array of JSON objects representing the local state for each conflict attempt.
+ Each object includes the local transaction ID (<literal>xid</literal>), commit
+ timestamp (<literal>commit_ts</literal>), origin (<literal>origin</literal>),
+ conflicting key data (<literal>key</literal>), and the full local row
+ image (<literal>tuple</literal>).
+ </entry>
+ </row>
+ </tbody>
+ </tgroup>
+ </table>
+
+ <para>
+ The conflicting row data, including the original local tuple and
+ the remote tuple, is stored in <type>JSON</type> columns (<literal>local_tuple</literal>
+ and <literal>remote_tuple</literal>) for flexible querying and analysis.
+ </para>
+
<para>
The log format for logical replication conflicts is as follows:
<synopsis>
@@ -2412,6 +2517,15 @@ CONTEXT: processing remote data for replication origin "pg_16395" during "INSER
key or replica identity defined for it.
</para>
</listitem>
+
+ <listitem>
+ <para>
+ The internal table automatically created when <literal>conflict_log_destination</literal>
+ is set to <literal>table</literal> or <literal>all</literal> is excluded from
+ logical replication. It will not be published, even if a publication on the
+ subscriber is defined using <literal>FOR ALL TABLES</literal>.
+ </para>
+ </listitem>
</itemizedlist>
</sect1>
diff --git a/doc/src/sgml/ref/alter_subscription.sgml b/doc/src/sgml/ref/alter_subscription.sgml
index 27c06439f4f..2f3bb0618f5 100644
--- a/doc/src/sgml/ref/alter_subscription.sgml
+++ b/doc/src/sgml/ref/alter_subscription.sgml
@@ -280,8 +280,9 @@ ALTER SUBSCRIPTION <replaceable class="parameter">name</replaceable> RENAME TO <
<link linkend="sql-createsubscription-params-with-origin"><literal>origin</literal></link>,
<link linkend="sql-createsubscription-params-with-failover"><literal>failover</literal></link>,
<link linkend="sql-createsubscription-params-with-two-phase"><literal>two_phase</literal></link>,
- <link linkend="sql-createsubscription-params-with-retain-dead-tuples"><literal>retain_dead_tuples</literal></link>, and
- <link linkend="sql-createsubscription-params-with-max-retention-duration"><literal>max_retention_duration</literal></link>.
+ <link linkend="sql-createsubscription-params-with-retain-dead-tuples"><literal>retain_dead_tuples</literal></link>,
+ <link linkend="sql-createsubscription-params-with-max-retention-duration"><literal>max_retention_duration</literal></link> and,
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>.
Only a superuser can set <literal>password_required = false</literal>.
</para>
@@ -339,6 +340,13 @@ ALTER SUBSCRIPTION <replaceable class="parameter">name</replaceable> RENAME TO <
<quote><literal>pg_conflict_detection</literal></quote>, created to retain
dead tuples for conflict detection, will be dropped.
</para>
+
+ <para>
+ When switching <literal>conflict_log_destination</literal> to <literal>table</literal>,
+ the system will ensure the internal logging table exists. If switching away
+ from <literal>table</literal>, the logging stops, but the previously recorded
+ data remains until the subscription is dropped.
+ </para>
</listitem>
</varlistentry>
diff --git a/doc/src/sgml/ref/create_subscription.sgml b/doc/src/sgml/ref/create_subscription.sgml
index 197be0c6f6b..3fa891bc4ae 100644
--- a/doc/src/sgml/ref/create_subscription.sgml
+++ b/doc/src/sgml/ref/create_subscription.sgml
@@ -274,6 +274,42 @@ CREATE SUBSCRIPTION <replaceable class="parameter">subscription_name</replaceabl
</listitem>
</varlistentry>
+ <varlistentry id="sql-createsubscription-params-with-conflict-log-destination">
+ <term><literal>conflict_log_destination</literal> (<type>enum</type>)</term>
+ <listitem>
+ <para>
+ Specifies the destination for recording logical replication conflicts.
+ The supported values are <literal>log</literal>, <literal>table</literal>,
+ and <literal>all</literal>. The default is <literal>log</literal>.
+ </para>
+ <para>
+ The available destinations are:
+ <itemizedlist>
+ <listitem>
+ <para>
+ <literal>log</literal>: Conflict details are recorded in the server log.
+ This is the default behavior.
+ </para>
+ </listitem>
+ <listitem>
+ <para>
+ <literal>table</literal>: The system automatically creates a structured table named
+ <literal>pg_conflict_<subid></literal> in the subscription owner's schema.
+ This allows for easy querying and analysis of conflicts. This table is
+ automatically dropped when the subscription is removed.
+ </para>
+ </listitem>
+ <listitem>
+ <para>
+ <literal>all</literal>: Records the conflict information to both the server log
+ and the dedicated conflict table.
+ </para>
+ </listitem>
+ </itemizedlist>
+ </para>
+ </listitem>
+ </varlistentry>
+
<varlistentry id="sql-createsubscription-params-with-streaming">
<term><literal>streaming</literal> (<type>enum</type>)</term>
<listitem>
--
2.43.0
[application/octet-stream] v15-0005-Preserve-conflict-log-destination-for-subscripti.patch (14.8K, ../../CALDaNm3M+OUNs+HTDrKfOXGGPEaeYJg5vQQZNozJ4MvoJ073Zg@mail.gmail.com/5-v15-0005-Preserve-conflict-log-destination-for-subscripti.patch)
download | inline diff:
From 0cccf05489d43cdf89cfd61a0561adb4e19163ec Mon Sep 17 00:00:00 2001
From: Vignesh C <vignesh21@gmail.com>
Date: Tue, 23 Dec 2025 11:12:47 +0530
Subject: [PATCH v15 5/6] Preserve conflict log destination for subscriptions
Support pg_dump to dump and restore the conflict_log_destination setting for
subscriptions.
During a normal CREATE SUBSCRIPTION, a conflict log table is created
automatically when required. However, during dump/restore or binary upgrade,
the conflict log table may already exist and must be reused rather than
recreated.
To ensure correct behavior, pg_dump now emits an ALTER SUBSCRIPTION command
after subscription creation to restore the conflict_log_destination setting.
While dumping, pg_dump temporarily sets the search_path to the schema in which
the conflict log table was created, ensuring that the conflict log table is
resolved with the appropriate schema.
---
src/backend/commands/subscriptioncmds.c | 174 +++++++++++++++++-------
src/bin/pg_dump/pg_dump.c | 59 +++++++-
src/bin/pg_dump/pg_dump.h | 2 +
src/bin/pg_dump/pg_dump_sort.c | 31 +++++
src/bin/pg_dump/t/002_pg_dump.pl | 7 +-
5 files changed, 220 insertions(+), 53 deletions(-)
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index b3694375191..1fbe0d474cf 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -1395,6 +1395,124 @@ CheckAlterSubOption(Subscription *sub, const char *option,
}
}
+/*
+ * AlterSubscriptionConflictLogDestination
+ *
+ * Update the conflict log table associated with a subscription when its
+ * conflict log destination is changed.
+ *
+ * If the new destination requires a conflict log table and none was previously
+ * required, this function validates an existing conflict log table identified
+ * by the subscription specific naming convention or creates a new one.
+ *
+ * If the new destination no longer requires a conflict log table, the existing
+ * conflict log table associated with the subscription is removed via internal
+ * dependency cleanup to prevent orphaned relations.
+ *
+ * The function enforces that any conflict log table used is a permanent
+ * relation in a permanent schema, matches the expected structure, and is not
+ * already associated with another subscription.
+ *
+ * On success, *conflicttablerelid is set to the OID of the conflict log table
+ * that was created or validated, or to InvalidOid if no table is required.
+ *
+ * Returns true if the subscription's conflict log table reference must be
+ * updated as a result of the destination change; false otherwise.
+ */
+static bool
+AlterSubscriptionConflictLogDestination(Subscription *sub,
+ ConflictLogDest logdest,
+ Oid *conflicttablerelid)
+{
+ ConflictLogDest old_dest = GetLogDestination(sub->logdestination);
+ bool want_table;
+ bool has_oldtable;
+ bool update_relid = false;
+ Oid relid = InvalidOid;
+
+ want_table = (logdest == CONFLICT_LOG_DEST_TABLE ||
+ logdest == CONFLICT_LOG_DEST_ALL);
+ has_oldtable = (old_dest == CONFLICT_LOG_DEST_TABLE ||
+ old_dest == CONFLICT_LOG_DEST_ALL);
+
+ if (want_table && !has_oldtable)
+ {
+ char relname[NAMEDATALEN];
+ Oid nspid;
+
+ GetConflictLogTableName(relname, sub->oid);
+ nspid = RangeVarGetCreationNamespace(makeRangeVar(NULL, relname, -1));
+ relid = get_relname_relid(relname, nspid);
+ if (OidIsValid(relid))
+ {
+ Relation conflictlogrel;
+ char *nspname = get_namespace_name(nspid);
+
+ /*
+ * Conflict log tables must be permanent relations. Disallow in
+ * temporary namespaces to ensure the same.
+ */
+ if (isTempNamespace(nspid))
+ ereport(ERROR,
+ errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot use conflict log table \"%s.%s\" of a temporary namespace",
+ nspname, relname),
+ errhint("Specify table from a permanent schema."));
+
+ conflictlogrel = table_open(relid, RowExclusiveLock);
+ if (conflictlogrel->rd_rel->relpersistence != RELPERSISTENCE_PERMANENT)
+ ereport(ERROR,
+ errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("conflict log table \"%s.%s\" must be a permanent table",
+ nspname, relname),
+ errhint("Specify a permanent table as the conflict log table."));
+
+ if (IsConflictLogTable(relid))
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("conflict log table \"%s.%s\" cannot be used",
+ nspname, relname),
+ errdetail("The specified table is already registered for a different subscription."),
+ errhint("Specify a different conflict log table."));
+ if (!ValidateConflictLogTable(conflictlogrel))
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("conflict log table \"%s.%s\" has an incompatible definition",
+ nspname, relname),
+ errdetail("The table does not match the required conflict log table structure."),
+ errhint("Create the conflict log table with the expected definition or specify a different table."));
+
+ table_close(conflictlogrel, NoLock);
+ }
+ else
+ relid = create_conflict_log_table(sub->oid, sub->name, nspid, relname);
+
+ update_relid = true;
+ }
+ else if (!want_table && has_oldtable)
+ {
+ ObjectAddress object;
+
+ /*
+ * Conflict log tables are recorded as internal dependencies of the
+ * subscription. Drop the table if it is not required anymore to
+ * avoid stale or orphaned relations.
+ *
+ * XXX: At present, only conflict log tables are managed this way. In
+ * future if we introduce additional internal dependencies, we may
+ * need a targeted deletion to avoid deletion of any other objects.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId, sub->oid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+ update_relid = true;
+ }
+
+ *conflicttablerelid = relid;
+ return update_relid;
+}
+
/*
* Alter the existing subscription.
*/
@@ -1738,65 +1856,23 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_LOG_DESTINATION))
{
ConflictLogDest old_dest =
- GetLogDestination(sub->logdestination);
+ GetLogDestination(sub->logdestination);
if (opts.logdest != old_dest)
{
- bool want_table =
- (opts.logdest == CONFLICT_LOG_DEST_TABLE ||
- opts.logdest == CONFLICT_LOG_DEST_ALL);
- bool has_oldtable =
- (old_dest == CONFLICT_LOG_DEST_TABLE ||
- old_dest == CONFLICT_LOG_DEST_ALL);
+ bool update_relid;
+ Oid relid = InvalidOid;
values[Anum_pg_subscription_sublogdestination - 1] =
CStringGetTextDatum(ConflictLogDestLabels[opts.logdest]);
replaces[Anum_pg_subscription_sublogdestination - 1] = true;
-
- if (want_table && !has_oldtable)
+ update_relid = AlterSubscriptionConflictLogDestination(sub, opts.logdest, &relid);
+ if (update_relid)
{
- char relname[NAMEDATALEN];
- Oid nspid;
- Oid relid;
-
- GetConflictLogTableName(relname, subid);
- nspid = RangeVarGetCreationNamespace(makeRangeVar(
- NULL, relname, -1));
-
- relid = create_conflict_log_table(subid, sub->name,
- nspid, relname);
-
- values[Anum_pg_subscription_subconflictlogrelid - 1] =
- ObjectIdGetDatum(relid);
- replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
- true;
- }
- else if (!want_table && has_oldtable)
- {
- ObjectAddress object;
-
- /*
- * Conflict log tables are recorded as internal
- * dependencies of the subscription. Drop the
- * table if it is not required anymore to avoid
- * stale or orphaned relations.
- *
- * XXX: At present, only conflict log tables are
- * managed this way. In future if we introduce
- * additional internal dependencies, we may need
- * a targeted deletion to avoid deletion of any
- * other objects.
- */
- ObjectAddressSet(object, SubscriptionRelationId,
- subid);
- performDeletion(&object, DROP_CASCADE,
- PERFORM_DELETION_INTERNAL |
- PERFORM_DELETION_SKIP_ORIGINAL);
-
values[Anum_pg_subscription_subconflictlogrelid - 1] =
- ObjectIdGetDatum(InvalidOid);
+ ObjectIdGetDatum(relid);
replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
- true;
+ true;
}
}
}
diff --git a/src/bin/pg_dump/pg_dump.c b/src/bin/pg_dump/pg_dump.c
index 27f6be3f0f8..d2477cfb5a1 100644
--- a/src/bin/pg_dump/pg_dump.c
+++ b/src/bin/pg_dump/pg_dump.c
@@ -5130,6 +5130,8 @@ getSubscriptions(Archive *fout)
int i_subfailover;
int i_subretaindeadtuples;
int i_submaxretention;
+ int i_subconflictlogrelid;
+ int i_sublogdestination;
int i,
ntups;
@@ -5216,10 +5218,17 @@ getSubscriptions(Archive *fout)
if (fout->remoteVersion >= 190000)
appendPQExpBufferStr(query,
- " s.submaxretention\n");
+ " s.submaxretention,\n");
else
appendPQExpBuffer(query,
- " 0 AS submaxretention\n");
+ " 0 AS submaxretention,\n");
+
+ if (fout->remoteVersion >= 190000)
+ appendPQExpBufferStr(query,
+ " s.subconflictlogrelid, s.sublogdestination\n");
+ else
+ appendPQExpBufferStr(query,
+ " NULL AS subconflictlogrelid, NULL AS sublogdestination\n");
appendPQExpBufferStr(query,
"FROM pg_subscription s\n");
@@ -5261,6 +5270,8 @@ getSubscriptions(Archive *fout)
i_subpublications = PQfnumber(res, "subpublications");
i_suborigin = PQfnumber(res, "suborigin");
i_suboriginremotelsn = PQfnumber(res, "suboriginremotelsn");
+ i_subconflictlogrelid = PQfnumber(res, "subconflictlogrelid");
+ i_sublogdestination = PQfnumber(res, "sublogdestination");
subinfo = pg_malloc(ntups * sizeof(SubscriptionInfo));
@@ -5309,6 +5320,33 @@ getSubscriptions(Archive *fout)
else
subinfo[i].suboriginremotelsn =
pg_strdup(PQgetvalue(res, i, i_suboriginremotelsn));
+ if (PQgetisnull(res, i, i_subconflictlogrelid))
+ subinfo[i].subconflictlogrelid = InvalidOid;
+ else
+ {
+ TableInfo *tableInfo;
+
+ subinfo[i].subconflictlogrelid =
+ atooid(PQgetvalue(res, i, i_subconflictlogrelid));
+
+ if (subinfo[i].subconflictlogrelid)
+ {
+ tableInfo = findTableByOid(subinfo[i].subconflictlogrelid);
+ if (!tableInfo)
+ pg_fatal("could not find conflict log table with OID %u",
+ subinfo[i].subconflictlogrelid);
+
+ addObjectDependency(&subinfo[i].dobj, tableInfo->dobj.dumpId);
+
+ if (!dopt->binary_upgrade)
+ tableInfo->dobj.dump = DUMP_COMPONENT_NONE;
+ }
+ }
+ if (PQgetisnull(res, i, i_sublogdestination))
+ subinfo[i].sublogdestination = NULL;
+ else
+ subinfo[i].sublogdestination =
+ pg_strdup(PQgetvalue(res, i, i_sublogdestination));
/* Decide whether we want to dump it */
selectDumpableObject(&(subinfo[i].dobj), fout);
@@ -5564,6 +5602,23 @@ dumpSubscription(Archive *fout, const SubscriptionInfo *subinfo)
appendPQExpBufferStr(query, ");\n");
+ if (subinfo->subconflictlogrelid)
+ {
+ TableInfo *tableInfo = findTableByOid(subinfo->subconflictlogrelid);
+
+ appendPQExpBuffer(query, "\n\nSELECT pg_catalog.set_config('search_path', '%s', false);\n",
+ tableInfo->dobj.namespace->dobj.name);
+ }
+
+ appendPQExpBuffer(query,
+ "\n\nALTER SUBSCRIPTION %s SET (conflict_log_destination = %s);\n",
+ qsubname,
+ subinfo->sublogdestination);
+
+
+ if (subinfo->subconflictlogrelid)
+ appendPQExpBufferStr(query, "\n\nSELECT pg_catalog.set_config('search_path', '', false);\n");
+
/*
* In binary-upgrade mode, we allow the replication to continue after the
* upgrade.
diff --git a/src/bin/pg_dump/pg_dump.h b/src/bin/pg_dump/pg_dump.h
index 72a00e1bc20..bd52c92140d 100644
--- a/src/bin/pg_dump/pg_dump.h
+++ b/src/bin/pg_dump/pg_dump.h
@@ -719,12 +719,14 @@ typedef struct _SubscriptionInfo
bool subfailover;
bool subretaindeadtuples;
int submaxretention;
+ Oid subconflictlogrelid;
char *subconninfo;
char *subslotname;
char *subsynccommit;
char *subpublications;
char *suborigin;
char *suboriginremotelsn;
+ char *sublogdestination;
} SubscriptionInfo;
/*
diff --git a/src/bin/pg_dump/pg_dump_sort.c b/src/bin/pg_dump/pg_dump_sort.c
index e2a4df4cf4b..2f170cae70f 100644
--- a/src/bin/pg_dump/pg_dump_sort.c
+++ b/src/bin/pg_dump/pg_dump_sort.c
@@ -1131,6 +1131,19 @@ repairTableAttrDefMultiLoop(DumpableObject *tableobj,
addObjectDependency(attrdefobj, tableobj->dumpId);
}
+/*
+ * Because we make subscriptions depend on their conflict log tables, while
+ * there is an automatic dependency in the other direction, we need to break
+ * the loop. Remove the automatic dependency, allowing the table to be created
+ * first.
+ */
+static void
+repairSubscriptionTableLoop(DumpableObject *subobj, DumpableObject *tableobj)
+{
+ /* Remove table's dependency on subscription */
+ removeObjectDependency(tableobj, subobj->dumpId);
+}
+
/*
* CHECK, NOT NULL constraints on domains work just like those on tables ...
*/
@@ -1361,6 +1374,24 @@ repairDependencyLoop(DumpableObject **loop,
return;
}
+ /*
+ * Subscription and its Conflict Log Table
+ */
+ if (nLoop == 2 &&
+ loop[0]->objType == DO_TABLE &&
+ loop[1]->objType == DO_SUBSCRIPTION)
+ {
+ repairSubscriptionTableLoop(loop[1], loop[0]);
+ return;
+ }
+ if (nLoop == 2 &&
+ loop[0]->objType == DO_SUBSCRIPTION &&
+ loop[1]->objType == DO_TABLE)
+ {
+ repairSubscriptionTableLoop(loop[0], loop[1]);
+ return;
+ }
+
/* index on partitioned table and corresponding index on partition */
if (nLoop == 2 &&
loop[0]->objType == DO_INDEX &&
diff --git a/src/bin/pg_dump/t/002_pg_dump.pl b/src/bin/pg_dump/t/002_pg_dump.pl
index e33aa95f6ff..8ec7b0069dd 100644
--- a/src/bin/pg_dump/t/002_pg_dump.pl
+++ b/src/bin/pg_dump/t/002_pg_dump.pl
@@ -3204,9 +3204,12 @@ my %tests = (
create_order => 50,
create_sql => 'CREATE SUBSCRIPTION sub3
CONNECTION \'dbname=doesnotexist\' PUBLICATION pub1
- WITH (connect = false, origin = any, streaming = on);',
+ WITH (connect = false, origin = any, streaming = on, conflict_log_destination= table);',
regexp => qr/^
- \QCREATE SUBSCRIPTION sub3 CONNECTION 'dbname=doesnotexist' PUBLICATION pub1 WITH (connect = false, slot_name = 'sub3', streaming = on);\E
+ \QCREATE SUBSCRIPTION sub3 CONNECTION 'dbname=doesnotexist' PUBLICATION pub1 WITH (connect = false, slot_name = 'sub3', streaming = on);\E\n\n\n
+ \QSELECT pg_catalog.set_config('search_path', 'public', false);\E\n\n\n
+ \QALTER SUBSCRIPTION sub3 SET (conflict_log_destination = table);\E\n\n\n
+ \QSELECT pg_catalog.set_config('search_path', '', false);\E
/xm,
like => { %full_runs, section_post_data => 1, },
unlike => {
--
2.43.0
[application/octet-stream] v15-0001-Add-configurable-conflict-log-table-for-Logical-.patch (97.9K, ../../CALDaNm3M+OUNs+HTDrKfOXGGPEaeYJg5vQQZNozJ4MvoJ073Zg@mail.gmail.com/6-v15-0001-Add-configurable-conflict-log-table-for-Logical-.patch)
download | inline diff:
From 828f133d7a4d7b672f210fb70054eb7e43e65e59 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 17 Dec 2025 11:53:47 +0530
Subject: [PATCH v15 1/6] Add configurable conflict log table for Logical
Replication
This patch adds a feature to provide a structured, queryable record of all
logical replication conflicts. The current approach of logging conflicts as
plain text in the server logs makes it difficult to query, analyze, and
use for external monitoring and automation.
This patch addresses these limitations by introducing a configurable
conflict_log_destination=(log/table/all) option in the CREATE SUBSCRIPTION
command.
If user choose to log into the table the table will automatically created while
creating the subscription with internal name i.e. conflict_log_table_$subid$. The
table will be created in the current search path and table would be automatically
dropped while dropping the subscription.
The conflict details, including the original and remote tuples, are stored in JSON
columns, providing a flexible format to accommodate different table schemas.
The log table captures essential attributes such as local and remote transaction IDs,
LSNs, commit timestamps, and conflict type, providing a complete record for post-mortem
analysis.
This feature will make logical replication conflicts easier to monitor and manage,
significantly improving the overall resilience and operability of replication setups.
The conflict log tables will not be included in a publication, even if the publication
is configured to include ALL TABLES or ALL TABLES IN SCHEMA.
---
src/backend/catalog/pg_publication.c | 26 +-
src/backend/catalog/pg_subscription.c | 7 +
src/backend/commands/subscriptioncmds.c | 337 ++++++++++++++++++++-
src/bin/psql/describe.c | 21 +-
src/bin/psql/tab-complete.in.c | 8 +-
src/include/catalog/pg_subscription.h | 11 +
src/include/commands/subscriptioncmds.h | 5 +
src/include/replication/conflict.h | 50 +++
src/test/regress/expected/subscription.out | 328 ++++++++++++++------
src/test/regress/sql/subscription.sql | 109 +++++++
src/tools/pgindent/typedefs.list | 1 +
11 files changed, 799 insertions(+), 104 deletions(-)
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index 7aa3f179924..9f84e02b7ef 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -31,6 +31,7 @@
#include "catalog/pg_publication_rel.h"
#include "catalog/pg_type.h"
#include "commands/publicationcmds.h"
+#include "commands/subscriptioncmds.h"
#include "funcapi.h"
#include "utils/array.h"
#include "utils/builtins.h"
@@ -85,6 +86,15 @@ check_publication_add_relation(Relation targetrel)
errmsg("cannot add relation \"%s\" to publication",
RelationGetRelationName(targetrel)),
errdetail("This operation is not supported for unlogged tables.")));
+
+ /* Can't be conflict log table */
+ if (IsConflictLogTable(RelationGetRelid(targetrel)))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot add relation \"%s.%s\" to publication",
+ get_namespace_name(RelationGetNamespace(targetrel)),
+ RelationGetRelationName(targetrel)),
+ errdetail("This operation is not supported for conflict log tables.")));
}
/*
@@ -145,6 +155,13 @@ is_publishable_class(Oid relid, Form_pg_class reltuple)
/*
* Another variant of is_publishable_class(), taking a Relation.
+ *
+ * Note: Conflict log tables are not publishable. However, we intentionally
+ * skip this check here because this function is called for every change and
+ * performing this check during every change publication is costly. To ensure
+ * unpublishable entries are ignored without incurring performance overhead,
+ * tuples inserted into the conflict log table uses the HEAP_INSERT_NO_LOGICAL
+ * flag. This allows the decoding layer to bypass these entries automatically.
*/
bool
is_publishable_relation(Relation rel)
@@ -169,7 +186,10 @@ pg_relation_is_publishable(PG_FUNCTION_ARGS)
tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
if (!HeapTupleIsValid(tuple))
PG_RETURN_NULL();
- result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple));
+
+ /* Subscription conflict log tables are not published */
+ result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple)) &&
+ !IsConflictLogTable(relid);
ReleaseSysCache(tuple);
PG_RETURN_BOOL(result);
}
@@ -890,7 +910,9 @@ GetAllPublicationRelations(char relkind, bool pubviaroot)
Form_pg_class relForm = (Form_pg_class) GETSTRUCT(tuple);
Oid relid = relForm->oid;
+ /* Subscription conflict log tables are not published */
if (is_publishable_class(relid, relForm) &&
+ !IsConflictLogTable(relid) &&
!(relForm->relispartition && pubviaroot))
result = lappend_oid(result, relid);
}
@@ -1018,7 +1040,7 @@ GetSchemaPublicationRelations(Oid schemaid, PublicationPartOpt pub_partopt)
Oid relid = relForm->oid;
char relkind;
- if (!is_publishable_class(relid, relForm))
+ if (!is_publishable_class(relid, relForm) || IsConflictLogTable(relid))
continue;
relkind = get_rel_relkind(relid);
diff --git a/src/backend/catalog/pg_subscription.c b/src/backend/catalog/pg_subscription.c
index ad6fbd77ffd..27a9aee1c56 100644
--- a/src/backend/catalog/pg_subscription.c
+++ b/src/backend/catalog/pg_subscription.c
@@ -106,6 +106,7 @@ GetSubscription(Oid subid, bool missing_ok)
sub->retaindeadtuples = subform->subretaindeadtuples;
sub->maxretention = subform->submaxretention;
sub->retentionactive = subform->subretentionactive;
+ sub->conflictrelid = subform->subconflictlogrelid;
/* Get conninfo */
datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
@@ -141,6 +142,12 @@ GetSubscription(Oid subid, bool missing_ok)
Anum_pg_subscription_suborigin);
sub->origin = TextDatumGetCString(datum);
+ /* Get conflict log destination */
+ datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
+ tup,
+ Anum_pg_subscription_sublogdestination);
+ sub->logdestination = TextDatumGetCString(datum);
+
/* Is the subscription owner a superuser? */
sub->ownersuperuser = superuser_arg(sub->owner);
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index abbcaff0838..05dcb5e3fe4 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -15,25 +15,30 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/htup_details.h"
#include "access/table.h"
#include "access/twophase.h"
#include "access/xact.h"
#include "catalog/catalog.h"
#include "catalog/dependency.h"
+#include "catalog/heap.h"
#include "catalog/indexing.h"
#include "catalog/namespace.h"
#include "catalog/objectaccess.h"
#include "catalog/objectaddress.h"
+#include "catalog/pg_am_d.h"
#include "catalog/pg_authid_d.h"
#include "catalog/pg_database_d.h"
#include "catalog/pg_subscription.h"
#include "catalog/pg_subscription_rel.h"
#include "catalog/pg_type.h"
+#include "commands/comment.h"
#include "commands/defrem.h"
#include "commands/event_trigger.h"
#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "miscadmin.h"
#include "nodes/makefuncs.h"
#include "pgstat.h"
@@ -51,6 +56,7 @@
#include "utils/lsyscache.h"
#include "utils/memutils.h"
#include "utils/pg_lsn.h"
+#include "utils/regproc.h"
#include "utils/syscache.h"
/*
@@ -75,6 +81,7 @@
#define SUBOPT_MAX_RETENTION_DURATION 0x00008000
#define SUBOPT_LSN 0x00010000
#define SUBOPT_ORIGIN 0x00020000
+#define SUBOPT_CONFLICT_LOG_DESTINATION 0x00040000
/* check if the 'val' has 'bits' set */
#define IsSet(val, bits) (((val) & (bits)) == (bits))
@@ -103,6 +110,7 @@ typedef struct SubOpts
bool retaindeadtuples;
int32 maxretention;
char *origin;
+ ConflictLogDest logdest;
XLogRecPtr lsn;
} SubOpts;
@@ -135,7 +143,8 @@ static List *merge_publications(List *oldpublist, List *newpublist, bool addpub,
static void ReportSlotConnectionError(List *rstates, Oid subid, char *slotname, char *err);
static void CheckAlterSubOption(Subscription *sub, const char *option,
bool slot_needs_update, bool isTopLevel);
-
+static Oid create_conflict_log_table(Oid subid, char *subname, Oid namespaceId,
+ char *conflictrel);
/*
* Common option parsing function for CREATE and ALTER SUBSCRIPTION commands.
@@ -191,6 +200,8 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->maxretention = 0;
if (IsSet(supported_opts, SUBOPT_ORIGIN))
opts->origin = pstrdup(LOGICALREP_ORIGIN_ANY);
+ if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DESTINATION))
+ opts->logdest = CONFLICT_LOG_DEST_LOG;
/* Parse options */
foreach(lc, stmt_options)
@@ -402,6 +413,28 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->specified_opts |= SUBOPT_LSN;
opts->lsn = lsn;
}
+ else if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DESTINATION) &&
+ strcmp(defel->defname, "conflict_log_destination") == 0)
+ {
+ char *val;
+ ConflictLogDest dest;
+
+ if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_LOG_DESTINATION))
+ errorConflictingDefElem(defel, pstate);
+
+ val = defGetString(defel);
+
+ dest = GetLogDestination(val);
+
+ if (dest == CONFLICT_LOG_DEST_INVALID)
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("unrecognized conflict_log_destination value: \"%s\"", val),
+ errhint("Valid values are \"log\", \"table\", and \"all\".")));
+
+ opts->logdest = dest;
+ opts->specified_opts |= SUBOPT_CONFLICT_LOG_DESTINATION;
+ }
else
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
@@ -612,7 +645,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
SUBOPT_DISABLE_ON_ERR | SUBOPT_PASSWORD_REQUIRED |
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
- SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN);
+ SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_DESTINATION);
parse_subscription_options(pstate, stmt->options, supported_opts, &opts);
/*
@@ -747,6 +781,40 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
values[Anum_pg_subscription_suborigin - 1] =
CStringGetTextDatum(opts.origin);
+ /* Always set the destination, default will be log. */
+ values[Anum_pg_subscription_sublogdestination - 1] =
+ CStringGetTextDatum(ConflictLogDestLabels[opts.logdest]);
+
+ /*
+ * If the conflict log destination includes 'table', generate an internal
+ * name using the subscription OID and determine the target namespace based
+ * on the current search path. Store the namespace OID and the conflict log
+ * format in the pg_subscription catalog tuple., then physically create
+ * the table.
+ */
+ if (opts.logdest == CONFLICT_LOG_DEST_TABLE ||
+ opts.logdest == CONFLICT_LOG_DEST_ALL)
+ {
+ char conflict_table_name[NAMEDATALEN];
+ Oid namespaceId, logrelid;
+
+ GetConflictLogTableName(conflict_table_name, subid);
+ namespaceId = RangeVarGetCreationNamespace(
+ makeRangeVar(NULL, conflict_table_name, -1));
+
+ /* Store the Oid returned from creation. */
+ logrelid = create_conflict_log_table(subid, stmt->subname, namespaceId,
+ conflict_table_name);
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(logrelid);
+ }
+ else
+ {
+ /* Destination is "log"; no table is needed. */
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(InvalidOid);
+ }
+
tup = heap_form_tuple(RelationGetDescr(rel), values, nulls);
/* Insert tuple into catalog. */
@@ -1410,7 +1478,8 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
SUBOPT_MAX_RETENTION_DURATION |
- SUBOPT_ORIGIN);
+ SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_DESTINATION);
parse_subscription_options(pstate, stmt->options,
supported_opts, &opts);
@@ -1665,6 +1734,72 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
origin = opts.origin;
}
+ if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_LOG_DESTINATION))
+ {
+ ConflictLogDest old_dest =
+ GetLogDestination(sub->logdestination);
+
+ if (opts.logdest != old_dest)
+ {
+ bool want_table =
+ (opts.logdest == CONFLICT_LOG_DEST_TABLE ||
+ opts.logdest == CONFLICT_LOG_DEST_ALL);
+ bool has_oldtable =
+ (old_dest == CONFLICT_LOG_DEST_TABLE ||
+ old_dest == CONFLICT_LOG_DEST_ALL);
+
+ values[Anum_pg_subscription_sublogdestination - 1] =
+ CStringGetTextDatum(ConflictLogDestLabels[opts.logdest]);
+ replaces[Anum_pg_subscription_sublogdestination - 1] = true;
+
+ if (want_table && !has_oldtable)
+ {
+ char relname[NAMEDATALEN];
+ Oid nspid;
+ Oid relid;
+
+ GetConflictLogTableName(relname, subid);
+ nspid = RangeVarGetCreationNamespace(makeRangeVar(
+ NULL, relname, -1));
+
+ relid = create_conflict_log_table(subid, sub->name,
+ nspid, relname);
+
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(relid);
+ replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
+ true;
+ }
+ else if (!want_table && has_oldtable)
+ {
+ ObjectAddress object;
+
+ /*
+ * Conflict log tables are recorded as internal
+ * dependencies of the subscription. Drop the
+ * table if it is not required anymore to avoid
+ * stale or orphaned relations.
+ *
+ * XXX: At present, only conflict log tables are
+ * managed this way. In future if we introduce
+ * additional internal dependencies, we may need
+ * a targeted deletion to avoid deletion of any
+ * other objects.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId,
+ subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(InvalidOid);
+ replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
+ true;
+ }
+ }
+ }
+
update_tuple = true;
break;
}
@@ -2027,6 +2162,7 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
Form_pg_subscription form;
List *rstates;
bool must_use_password;
+ ObjectAddress object;
/*
* The launcher may concurrently start a new worker for this subscription.
@@ -2184,6 +2320,19 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
/* Clean up dependencies */
deleteSharedDependencyRecordsFor(SubscriptionRelationId, subid, 0);
+ /*
+ * Conflict log tables are recorded as internal dependencies of the
+ * subscription. We must drop the dependent objects before the
+ * subscription itself is removed. By using
+ * PERFORM_DELETION_SKIP_ORIGINAL, we ensure that only the conflict log
+ * table is reaped while the subscription remains for the final deletion
+ * step.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId, subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
/* Remove any associated relation synchronization states. */
RemoveSubscriptionRel(subid, InvalidOid);
@@ -3188,3 +3337,185 @@ defGetStreamingMode(DefElem *def)
def->defname)));
return LOGICALREP_STREAM_OFF; /* keep compiler quiet */
}
+
+/*
+ * Builds the TupleDesc for the conflict log table.
+ */
+static TupleDesc
+create_conflict_log_table_tupdesc(void)
+{
+ TupleDesc tupdesc;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_CONFLICT_ATTR_NUM);
+
+ for (int i = 0; i < MAX_CONFLICT_ATTR_NUM; i++)
+ {
+ Oid type_oid = ConflictLogSchema[i].atttypid;
+
+ /*
+ * Special handling for the JSON array type for proper
+ * TupleDescInitEntry call.
+ */
+ if (type_oid == JSONARRAYOID)
+ type_oid = get_array_type(JSONOID);
+
+ TupleDescInitEntry(tupdesc, i + 1,
+ ConflictLogSchema[i].attname,
+ type_oid,
+ -1, 0);
+ }
+
+ return BlessTupleDesc(tupdesc);
+}
+
+/*
+ * Create conflict log table.
+ *
+ * The subscription owner becomes the owner of this table and has all
+ * privileges on it.
+ */
+static Oid
+create_conflict_log_table(Oid subid, char *subname, Oid namespaceId,
+ char *conflictrel)
+{
+ TupleDesc tupdesc;
+ Oid relid;
+ char comment[256];
+ ObjectAddress myself;
+ ObjectAddress subaddr;
+
+ /*
+ * Conflict log tables must be permanent relations. Disallow creation in
+ * temporary namespaces to ensure the same.
+ */
+ if (isTempNamespace(namespaceId))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("could not generate conflict log table \"%s\"",
+ conflictrel),
+ errdetail("Conflict log tables cannot be created in a temporary namespace."),
+ errhint("Ensure your 'search_path' is set to permanent schema.")));
+
+ /* Report an error if the specified conflict log table already exists. */
+ if (OidIsValid(get_relname_relid(conflictrel, namespaceId)))
+ ereport(ERROR,
+ (errcode(ERRCODE_DUPLICATE_TABLE),
+ errmsg("could not generate conflict log table \"%s.%s\"",
+ get_namespace_name(namespaceId), conflictrel),
+ errdetail("A table with the internally generated name already exists."),
+ errhint("Drop the existing table or change your 'search_path' to use a different schema.")));
+
+ /* Build the tuple descriptor for the new table. */
+ tupdesc = create_conflict_log_table_tupdesc();
+
+ /* Create conflict log table. */
+ relid = heap_create_with_catalog(conflictrel,
+ namespaceId,
+ 0,
+ InvalidOid,
+ InvalidOid,
+ InvalidOid,
+ GetUserId(),
+ HEAP_TABLE_AM_OID,
+ tupdesc,
+ NIL,
+ RELKIND_RELATION,
+ RELPERSISTENCE_PERMANENT,
+ false,
+ false,
+ ONCOMMIT_NOOP,
+ (Datum) 0,
+ false,
+ false,
+ false,
+ InvalidOid,
+ NULL);
+
+ /* Add a comments for the conflict log table. */
+ snprintf(comment, sizeof(comment),
+ "Conflict log table for subscription \"%s\"", subname);
+ CreateComments(relid, RelationRelationId, 0, comment);
+
+ /*
+ * Establish an internal dependency between the conflict log table and
+ * the subscription. By using DEPENDENCY_INTERNAL, we ensure the table
+ * is automatically reaped when the subscription is dropped. This also
+ * prevents the table from being dropped independently unless the
+ * subscription itself is removed.
+ */
+ ObjectAddressSet(myself, RelationRelationId, relid);
+ ObjectAddressSet(subaddr, SubscriptionRelationId, subid);
+ recordDependencyOn(&myself, &subaddr, DEPENDENCY_INTERNAL);
+
+ /* Release tuple descriptor memory. */
+ FreeTupleDesc(tupdesc);
+
+ return relid;
+}
+
+/*
+ * Format the standardized internal conflict log table name for a subscription
+ *
+ * Use the OID to prevent collisions during rename operations.
+ */
+void
+GetConflictLogTableName(char *dest, Oid subid)
+{
+ snprintf(dest, NAMEDATALEN, "conflict_log_table_%u", subid);
+}
+
+/*
+ * GetLogDestination
+ *
+ * Convert string to enum by comparing against standardized labels.
+ */
+ConflictLogDest
+GetLogDestination(const char *dest)
+{
+ /* Empty string or NULL defaults to LOG. */
+ if (dest == NULL || dest[0] == '\0')
+ return CONFLICT_LOG_DEST_LOG;
+
+ for (int i = CONFLICT_LOG_DEST_LOG; i <= CONFLICT_LOG_DEST_ALL; i++)
+ {
+ if (pg_strcasecmp(dest, ConflictLogDestLabels[i]) == 0)
+ return (ConflictLogDest) i;
+ }
+
+ /* Unrecognized string. */
+ return CONFLICT_LOG_DEST_INVALID;
+}
+
+/*
+ * Check if the specified relation is used as a conflict log table by any
+ * subscription.
+ */
+bool
+IsConflictLogTable(Oid relid)
+{
+ Relation rel;
+ TableScanDesc scan;
+ HeapTuple tup;
+ bool is_clt = false;
+
+ rel = table_open(SubscriptionRelationId, AccessShareLock);
+ scan = table_beginscan_catalog(rel, 0, NULL);
+
+ while (HeapTupleIsValid(tup = heap_getnext(scan, ForwardScanDirection)))
+ {
+ Form_pg_subscription subform = (Form_pg_subscription) GETSTRUCT(tup);
+
+ /* Direct Oid comparison from catalog */
+ if (OidIsValid(subform->subconflictlogrelid) &&
+ subform->subconflictlogrelid == relid)
+ {
+ is_clt = true;
+ break;
+ }
+ }
+
+ table_endscan(scan);
+ table_close(rel, AccessShareLock);
+
+ return is_clt;
+}
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index 36f24502842..cc80f0f661c 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -6806,7 +6806,7 @@ describeSubscriptions(const char *pattern, bool verbose)
printQueryOpt myopt = pset.popt;
static const bool translate_columns[] = {false, false, false, false,
false, false, false, false, false, false, false, false, false, false,
- false, false, false, false};
+ false, false, false, false, false};
if (pset.sversion < 100000)
{
@@ -6900,15 +6900,22 @@ describeSubscriptions(const char *pattern, bool verbose)
appendPQExpBuffer(&buf,
", subskiplsn AS \"%s\"\n",
gettext_noop("Skip LSN"));
+
+ /* Conflict log destination is supported in v19 and higher */
+ if (pset.sversion >= 190000)
+ {
+ appendPQExpBuffer(&buf,
+ ", sublogdestination AS \"%s\"\n",
+ gettext_noop("Conflict log destination"));
+ }
}
/* Only display subscriptions in current database. */
- appendPQExpBufferStr(&buf,
- "FROM pg_catalog.pg_subscription\n"
- "WHERE subdbid = (SELECT oid\n"
- " FROM pg_catalog.pg_database\n"
- " WHERE datname = pg_catalog.current_database())");
-
+ appendPQExpBuffer(&buf,
+ "FROM pg_catalog.pg_subscription "
+ "WHERE subdbid = (SELECT oid\n"
+ " FROM pg_catalog.pg_database\n"
+ " WHERE datname = pg_catalog.current_database())");
if (!validateSQLNamePattern(&buf, pattern, true, false,
NULL, "subname", NULL,
NULL,
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index b1ff6f6cd94..fd279d98b1b 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -2344,8 +2344,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("(", "PUBLICATION");
/* ALTER SUBSCRIPTION <name> SET ( */
else if (Matches("ALTER", "SUBSCRIPTION", MatchAny, MatchAnyN, "SET", "("))
- COMPLETE_WITH("binary", "disable_on_error", "failover",
- "max_retention_duration", "origin",
+ COMPLETE_WITH("binary", "conflict_log_destination", "disable_on_error",
+ "failover", "max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
"synchronous_commit", "two_phase");
@@ -3828,8 +3828,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("WITH (");
/* Complete "CREATE SUBSCRIPTION <name> ... WITH ( <opt>" */
else if (Matches("CREATE", "SUBSCRIPTION", MatchAnyN, "WITH", "("))
- COMPLETE_WITH("binary", "connect", "copy_data", "create_slot",
- "disable_on_error", "enabled", "failover",
+ COMPLETE_WITH("binary", "conflict_log_destination", "connect", "copy_data",
+ "create_slot", "disable_on_error", "enabled", "failover",
"max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index 55cb9b1eefa..55f4bfa0419 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -90,6 +90,7 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
* exceeded max_retention_duration, when
* defined */
+ Oid subconflictlogrelid; /* Relid of the conflict log table. */
#ifdef CATALOG_VARLEN /* variable-length fields start here */
/* Connection string to the publisher */
text subconninfo BKI_FORCE_NOT_NULL;
@@ -103,6 +104,14 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
/* List of publications subscribed to */
text subpublications[1] BKI_FORCE_NOT_NULL;
+ /*
+ * Strategy for logging replication conflicts:
+ * log - server log only,
+ * table - internal table only,
+ * all - both log and table.
+ */
+ text sublogdestination;
+
/* Only publish data originating from the specified origin */
text suborigin BKI_DEFAULT(LOGICALREP_ORIGIN_ANY);
#endif
@@ -152,12 +161,14 @@ typedef struct Subscription
* and the retention duration has not
* exceeded max_retention_duration, when
* defined */
+ Oid conflictrelid; /* conflict log table Oid */
char *conninfo; /* Connection string to the publisher */
char *slotname; /* Name of the replication slot */
char *synccommit; /* Synchronous commit setting for worker */
List *publications; /* List of publication names to subscribe to */
char *origin; /* Only publish data originating from the
* specified origin */
+ char *logdestination; /* Conflict log destination */
} Subscription;
#ifdef EXPOSE_TO_CLIENT_CODE
diff --git a/src/include/commands/subscriptioncmds.h b/src/include/commands/subscriptioncmds.h
index fb4e26a51a4..255e1e241b8 100644
--- a/src/include/commands/subscriptioncmds.h
+++ b/src/include/commands/subscriptioncmds.h
@@ -17,6 +17,7 @@
#include "catalog/objectaddress.h"
#include "parser/parse_node.h"
+#include "replication/conflict.h"
extern ObjectAddress CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bool isTopLevel);
@@ -36,4 +37,8 @@ extern void CheckSubDeadTupleRetention(bool check_guc, bool sub_disabled,
bool retention_active,
bool max_retention_set);
+extern void GetConflictLogTableName(char *dest, Oid subid);
+extern ConflictLogDest GetLogDestination(const char *dest);
+extern bool IsConflictLogTable(Oid relid);
+
#endif /* SUBSCRIPTIONCMDS_H */
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index c8fbf9e51b8..70f8744b381 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -10,6 +10,7 @@
#define CONFLICT_H
#include "access/xlogdefs.h"
+#include "catalog/pg_type.h"
#include "nodes/pg_list.h"
#include "utils/timestamp.h"
@@ -79,6 +80,55 @@ typedef struct ConflictTupleInfo
* conflicting local row occurred */
} ConflictTupleInfo;
+/*
+ * Conflict log destination types.
+ *
+ * Internally we use these enum values for fast comparison, but we store
+ * the string equivalent in pg_subscription.sublogdestination.
+ */
+typedef enum ConflictLogDest
+{
+ CONFLICT_LOG_DEST_INVALID = 0,
+ CONFLICT_LOG_DEST_LOG, /* "log" (default) */
+ CONFLICT_LOG_DEST_TABLE, /* "table" */
+ CONFLICT_LOG_DEST_ALL /* "all" */
+} ConflictLogDest;
+
+/*
+ * Array mapping for converting internal enum to string.
+ */
+static const char *const ConflictLogDestLabels[] = {
+ [CONFLICT_LOG_DEST_LOG] = "log",
+ [CONFLICT_LOG_DEST_TABLE] = "table",
+ [CONFLICT_LOG_DEST_ALL] = "all"
+};
+
+/* Structure to hold metadata for one column of the conflict log table */
+typedef struct ConflictLogColumnDef
+{
+ const char *attname; /* Column name */
+ Oid atttypid; /* Data type OID */
+} ConflictLogColumnDef;
+
+/* The single source of truth for the conflict log table schema */
+static const ConflictLogColumnDef ConflictLogSchema[] =
+{
+ { .attname = "relid", .atttypid = OIDOID },
+ { .attname = "schemaname", .atttypid = TEXTOID },
+ { .attname = "relname", .atttypid = TEXTOID },
+ { .attname = "conflict_type", .atttypid = TEXTOID },
+ { .attname = "remote_xid", .atttypid = XIDOID },
+ { .attname = "remote_commit_lsn",.atttypid = LSNOID },
+ { .attname = "remote_commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "remote_origin", .atttypid = TEXTOID },
+ { .attname = "replica_identity", .atttypid = JSONOID },
+ { .attname = "remote_tuple", .atttypid = JSONOID },
+ { .attname = "local_conflicts", .atttypid = JSONARRAYOID }
+};
+
+/* Define the count using the array size */
+#define MAX_CONFLICT_ATTR_NUM (sizeof(ConflictLogSchema) / sizeof(ConflictLogSchema[0]))
+
extern bool GetTupleTransactionInfo(TupleTableSlot *localslot,
TransactionId *xmin,
RepOriginId *localorigin,
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index 327d1e7731f..4ab58d90925 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -116,18 +116,18 @@ CREATE SUBSCRIPTION regress_testsub4 CONNECTION 'dbname=regress_doesnotexist' PU
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub4 SET (origin = any);
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub3;
@@ -145,10 +145,10 @@ ALTER SUBSCRIPTION regress_testsub CONNECTION 'foobar';
ERROR: invalid connection string syntax: missing "=" after "foobar" in connection info string
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET PUBLICATION testpub2, testpub3 WITH (refresh = false);
@@ -157,10 +157,10 @@ ALTER SUBSCRIPTION regress_testsub SET (slot_name = 'newname');
ALTER SUBSCRIPTION regress_testsub SET (password_required = false);
ALTER SUBSCRIPTION regress_testsub SET (run_as_owner = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (password_required = true);
@@ -176,10 +176,10 @@ ERROR: unrecognized subscription parameter: "create_slot"
-- ok
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/12345');
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345 | log
(1 row)
-- ok - with lsn = NONE
@@ -188,10 +188,10 @@ ALTER SUBSCRIPTION regress_testsub SKIP (lsn = NONE);
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/0');
ERROR: invalid WAL location (LSN): 0/0
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
BEGIN;
@@ -223,10 +223,10 @@ ALTER SUBSCRIPTION regress_testsub_foo SET (synchronous_commit = foobar);
ERROR: invalid value for parameter "synchronous_commit": "foobar"
HINT: Available values: local, remote_write, remote_apply, on, off.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
----------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+---------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
-- rename back to keep the rest simple
@@ -255,19 +255,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (binary = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -279,27 +279,27 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = parallel);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- fail - publication already exists
@@ -314,10 +314,10 @@ ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refr
ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refresh = false);
ERROR: publication "testpub1" is already in subscription "regress_testsub"
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- fail - publication used more than once
@@ -332,10 +332,10 @@ ERROR: publication "testpub3" is not in subscription "regress_testsub"
-- ok - delete publications
ALTER SUBSCRIPTION regress_testsub DROP PUBLICATION testpub1, testpub2 WITH (refresh = false);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -371,19 +371,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- we can alter streaming when two_phase enabled
ALTER SUBSCRIPTION regress_testsub SET (streaming = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -393,10 +393,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -409,18 +409,18 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (disable_on_error = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -433,10 +433,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -450,19 +450,19 @@ NOTICE: max_retention_duration is ineffective when retain_dead_tuples is disabl
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- ok
ALTER SUBSCRIPTION regress_testsub SET (max_retention_duration = 0);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -517,7 +517,159 @@ COMMIT;
-- ok, owning it is enough for this stuff
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG DESTINATION TESTS
+--
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+-- fail - unrecognized format value
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
+ERROR: unrecognized conflict_log_destination value: "invalid"
+HINT: Valid values are "log", "table", and "all".
+-- verify sublogdestination is 'log' and relid is 0 (InvalidOid) for default case
+CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+SELECT subname, sublogdestination, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
+ subname | sublogdestination | subconflictlogrelid
+------------------------------+-------------------+---------------------
+ regress_conflict_log_default | log | 0
+(1 row)
+
+-- verify empty string defaults to 'log'
+CREATE SUBSCRIPTION regress_conflict_empty_str CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = '');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+SELECT subname, sublogdestination, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
+ subname | sublogdestination | subconflictlogrelid
+----------------------------+-------------------+---------------------
+ regress_conflict_empty_str | log | 0
+(1 row)
+
+-- this should generate an internal table named conflict_log_table_$subid$
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- check metadata in pg_subscription: destination should be 'table' and relid valid
+SELECT subname, sublogdestination, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname | sublogdestination | has_relid
+------------------------+-------------------+-----------
+ regress_conflict_test1 | table | t
+(1 row)
+
+-- verify the physical table exists and its OID matches subconflictlogrelid
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND c.oid = s.subconflictlogrelid;
+ count
+-------
+ 1
+(1 row)
+
+-- check if the internal table has the correct schema (11 columns)
+SELECT count(*)
+FROM pg_attribute a
+JOIN pg_class c ON a.attrelid = c.oid
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0;
+ count
+-------
+ 11
+(1 row)
+
+-- ALTER: State transitions
+-- transition from 'log' to 'all'
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
+-- verify metadata after ALTER (destination should be 'all')
+SELECT subname, sublogdestination, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | sublogdestination | has_relid
+------------------------+-------------------+-----------
+ regress_conflict_test2 | all | t
+(1 row)
+
+-- transition from 'all' to 'table' (should NOT drop the table, only change destination string)
+SELECT subconflictlogrelid AS old_relid FROM pg_subscription WHERE subname = 'regress_conflict_test2' \gset
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'table');
+SELECT sublogdestination, subconflictlogrelid = :old_relid AS relid_unchanged
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ sublogdestination | relid_unchanged
+-------------------+-----------------
+ table | t
+(1 row)
+
+-- transition from 'table' to 'log' (should drop the table and clear relid)
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log');
+SELECT sublogdestination, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ sublogdestination | subconflictlogrelid
+-------------------+---------------------
+ log | 0
+(1 row)
+
+-- verify the physical table is gone
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test2';
+ count
+-------
+ 0
+(1 row)
+
+-- ensure drop table not allowed and DROP SUBSCRIPTION reaps the table
+-- re-enable table logging for verification
+ALTER SUBSCRIPTION regress_conflict_test1 SET (conflict_log_destination = 'table');
+-- fail - drop table not allowed due to internal dependency
+-- use DO block to hide OID in error message
+DO $$
+BEGIN
+ EXECUTE 'DROP TABLE ' || (SELECT 'conflict_log_table_' || oid FROM pg_subscription WHERE subname = 'regress_conflict_test1');
+EXCEPTION WHEN dependent_objects_still_exist THEN
+ RAISE NOTICE 'captured expected error: dependent_objects_still_exist';
+END $$;
+NOTICE: captured expected error: dependent_objects_still_exist
+-- PUBLICATION: Verify internal tables are not publishable
+-- pg_relation_is_publishable should return false for internal conflict log tables
+SELECT pg_relation_is_publishable(subconflictlogrelid)
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ pg_relation_is_publishable
+----------------------------
+ f
+(1 row)
+
+-- CLEANUP: Proper drop reaps the table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+-- Verify the table OID for reap check
+SELECT 'conflict_log_table_' || oid AS internal_tablename FROM pg_subscription WHERE subname = 'regress_conflict_test1' \gset
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the internal table was reaped via dependency
+SELECT to_regclass(:'internal_tablename');
+ to_regclass
+-------------
+
+(1 row)
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_log_default DISABLE;
+ALTER SUBSCRIPTION regress_conflict_log_default SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_log_default;
+ALTER SUBSCRIPTION regress_conflict_empty_str DISABLE;
+ALTER SUBSCRIPTION regress_conflict_empty_str SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_empty_str;
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
RESET SESSION AUTHORIZATION;
+DROP SCHEMA clt;
+ERROR: schema "clt" does not exist
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
DROP ROLE regress_subscription_user3;
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index ef0c298d2df..3359ff8be5c 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -365,7 +365,116 @@ COMMIT;
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG DESTINATION TESTS
+--
+
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+
+-- fail - unrecognized format value
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
+
+-- verify sublogdestination is 'log' and relid is 0 (InvalidOid) for default case
+CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+SELECT subname, sublogdestination, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
+
+-- verify empty string defaults to 'log'
+CREATE SUBSCRIPTION regress_conflict_empty_str CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = '');
+SELECT subname, sublogdestination, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
+
+-- this should generate an internal table named conflict_log_table_$subid$
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+
+-- check metadata in pg_subscription: destination should be 'table' and relid valid
+SELECT subname, sublogdestination, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- verify the physical table exists and its OID matches subconflictlogrelid
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND c.oid = s.subconflictlogrelid;
+
+-- check if the internal table has the correct schema (11 columns)
+SELECT count(*)
+FROM pg_attribute a
+JOIN pg_class c ON a.attrelid = c.oid
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0;
+
+-- ALTER: State transitions
+-- transition from 'log' to 'all'
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
+
+-- verify metadata after ALTER (destination should be 'all')
+SELECT subname, sublogdestination, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'all' to 'table' (should NOT drop the table, only change destination string)
+SELECT subconflictlogrelid AS old_relid FROM pg_subscription WHERE subname = 'regress_conflict_test2' \gset
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'table');
+SELECT sublogdestination, subconflictlogrelid = :old_relid AS relid_unchanged
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'table' to 'log' (should drop the table and clear relid)
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log');
+SELECT sublogdestination, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- verify the physical table is gone
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test2';
+
+-- ensure drop table not allowed and DROP SUBSCRIPTION reaps the table
+-- re-enable table logging for verification
+ALTER SUBSCRIPTION regress_conflict_test1 SET (conflict_log_destination = 'table');
+
+-- fail - drop table not allowed due to internal dependency
+-- use DO block to hide OID in error message
+DO $$
+BEGIN
+ EXECUTE 'DROP TABLE ' || (SELECT 'conflict_log_table_' || oid FROM pg_subscription WHERE subname = 'regress_conflict_test1');
+EXCEPTION WHEN dependent_objects_still_exist THEN
+ RAISE NOTICE 'captured expected error: dependent_objects_still_exist';
+END $$;
+
+-- PUBLICATION: Verify internal tables are not publishable
+-- pg_relation_is_publishable should return false for internal conflict log tables
+SELECT pg_relation_is_publishable(subconflictlogrelid)
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- CLEANUP: Proper drop reaps the table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+
+-- Verify the table OID for reap check
+SELECT 'conflict_log_table_' || oid AS internal_tablename FROM pg_subscription WHERE subname = 'regress_conflict_test1' \gset
+
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the internal table was reaped via dependency
+SELECT to_regclass(:'internal_tablename');
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_log_default DISABLE;
+ALTER SUBSCRIPTION regress_conflict_log_default SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_log_default;
+
+ALTER SUBSCRIPTION regress_conflict_empty_str DISABLE;
+ALTER SUBSCRIPTION regress_conflict_empty_str SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_empty_str;
+
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+
RESET SESSION AUTHORIZATION;
+DROP SCHEMA clt;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
DROP ROLE regress_subscription_user3;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 04845d5e680..21826be5bd7 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -500,6 +500,7 @@ ConditionVariableMinimallyPadded
ConditionalStack
ConfigData
ConfigVariable
+ConflictLogColumnDef
ConflictTupleInfo
ConflictType
ConnCacheEntry
--
2.43.0
[application/octet-stream] v15-0006-logical-replication-allow-combined-conflict_log_.patch (59.5K, ../../CALDaNm3M+OUNs+HTDrKfOXGGPEaeYJg5vQQZNozJ4MvoJ073Zg@mail.gmail.com/7-v15-0006-logical-replication-allow-combined-conflict_log_.patch)
download | inline diff:
From e6a1c0a6f88f421f66a93a2483ca585f2a298c46 Mon Sep 17 00:00:00 2001
From: Vignesh C <vignesh21@gmail.com>
Date: Wed, 24 Dec 2025 15:47:01 +0530
Subject: [PATCH v15 6/6] logical replication: allow combined
conflict_log_destination settings
Extend conflict_log_destination handling to support combined destination
specifications. Previously, only log, table, or all were accepted. This change
allows combinations of them like log, table and all, log, table etc
---
src/backend/catalog/pg_subscription.c | 2 +-
src/backend/commands/subscriptioncmds.c | 95 +++++++++++++++-------
src/backend/replication/logical/conflict.c | 9 +-
src/bin/pg_dump/pg_dump.c | 42 ++++++----
src/bin/pg_dump/t/002_pg_dump.pl | 4 +-
src/include/catalog/pg_subscription.h | 4 +-
src/include/commands/subscriptioncmds.h | 2 +-
src/include/replication/conflict.h | 23 +++---
src/test/regress/expected/subscription.out | 72 +++++++++-------
src/test/regress/sql/subscription.sql | 11 ++-
src/tools/pgindent/typedefs.list | 3 +-
11 files changed, 170 insertions(+), 97 deletions(-)
diff --git a/src/backend/catalog/pg_subscription.c b/src/backend/catalog/pg_subscription.c
index dae44c659f8..c1b8c2870c5 100644
--- a/src/backend/catalog/pg_subscription.c
+++ b/src/backend/catalog/pg_subscription.c
@@ -147,7 +147,7 @@ GetSubscription(Oid subid, bool missing_ok)
datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
tup,
Anum_pg_subscription_sublogdestination);
- sub->logdestination = TextDatumGetCString(datum);
+ sub->logdestination = textarray_to_stringlist(DatumGetArrayTypeP(datum));
/* Is the subscription owner a superuser? */
sub->ownersuperuser = superuser_arg(sub->owner);
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index 1fbe0d474cf..9809b8b56f4 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -59,6 +59,7 @@
#include "utils/pg_lsn.h"
#include "utils/regproc.h"
#include "utils/syscache.h"
+#include "utils/varlena.h"
/*
* Options that can be specified by the user in CREATE/ALTER SUBSCRIPTION
@@ -417,23 +418,22 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
else if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DESTINATION) &&
strcmp(defel->defname, "conflict_log_destination") == 0)
{
- char *val;
- ConflictLogDest dest;
+ char *val;
+ List *dest;
if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_LOG_DESTINATION))
errorConflictingDefElem(defel, pstate);
val = defGetString(defel);
- dest = GetLogDestination(val);
-
- if (dest == CONFLICT_LOG_DEST_INVALID)
+ if (!SplitIdentifierString(val, ',', &dest))
ereport(ERROR,
- (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
- errmsg("unrecognized conflict_log_destination value: \"%s\"", val),
- errhint("Valid values are \"log\", \"table\", and \"all\".")));
+ errcode(ERRCODE_SYNTAX_ERROR),
+ errmsg("invalid list syntax in parameter \"%s\"",
+ "conflict_log_destination"));
+
+ opts->logdest = GetLogDestination(dest, false);
- opts->logdest = dest;
opts->specified_opts |= SUBOPT_CONFLICT_LOG_DESTINATION;
}
else
@@ -613,6 +613,30 @@ publicationListToArray(List *publist)
return PointerGetDatum(arr);
}
+/*
+ * Build a text[] array representing the conflict_log_destination flags.
+ */
+static Datum
+ConflictLogDestFlagsToArray(ConflictLogDest logdest)
+{
+ Datum datums[3];
+ int ndatums = 0;
+
+ if (CONFLICT_LOG_DEST_ALL_ENABLED(logdest))
+ datums[ndatums++] = CStringGetTextDatum("all");
+ else
+ {
+ if (CONFLICT_LOG_DEST_LOG_ENABLED(logdest))
+ datums[ndatums++] = CStringGetTextDatum("log");
+
+ if (CONFLICT_LOG_DEST_TABLE_ENABLED(logdest))
+ datums[ndatums++] = CStringGetTextDatum("table");
+ }
+
+ return PointerGetDatum(
+ construct_array_builtin(datums, ndatums, TEXTOID));
+}
+
/*
* Create new subscription.
*/
@@ -784,7 +808,7 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
/* Always set the destination, default will be log. */
values[Anum_pg_subscription_sublogdestination - 1] =
- CStringGetTextDatum(ConflictLogDestLabels[opts.logdest]);
+ ConflictLogDestFlagsToArray(opts.logdest);
/*
* If the conflict log destination includes 'table', generate an internal
@@ -793,8 +817,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
* format in the pg_subscription catalog tuple., then physically create
* the table.
*/
- if (opts.logdest == CONFLICT_LOG_DEST_TABLE ||
- opts.logdest == CONFLICT_LOG_DEST_ALL)
+ if (CONFLICT_LOG_DEST_TABLE_ENABLED(opts.logdest) ||
+ CONFLICT_LOG_DEST_ALL_ENABLED(opts.logdest))
{
char conflict_table_name[NAMEDATALEN];
Oid namespaceId, logrelid;
@@ -1424,16 +1448,16 @@ AlterSubscriptionConflictLogDestination(Subscription *sub,
ConflictLogDest logdest,
Oid *conflicttablerelid)
{
- ConflictLogDest old_dest = GetLogDestination(sub->logdestination);
+ ConflictLogDest old_dest = GetLogDestination(sub->logdestination, true);
bool want_table;
bool has_oldtable;
bool update_relid = false;
Oid relid = InvalidOid;
- want_table = (logdest == CONFLICT_LOG_DEST_TABLE ||
- logdest == CONFLICT_LOG_DEST_ALL);
- has_oldtable = (old_dest == CONFLICT_LOG_DEST_TABLE ||
- old_dest == CONFLICT_LOG_DEST_ALL);
+ want_table = (CONFLICT_LOG_DEST_TABLE_ENABLED(logdest) ||
+ CONFLICT_LOG_DEST_ALL_ENABLED(logdest));
+ has_oldtable = (CONFLICT_LOG_DEST_TABLE_ENABLED(old_dest) ||
+ CONFLICT_LOG_DEST_ALL_ENABLED(old_dest));
if (want_table && !has_oldtable)
{
@@ -1856,7 +1880,7 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_LOG_DESTINATION))
{
ConflictLogDest old_dest =
- GetLogDestination(sub->logdestination);
+ GetLogDestination(sub->logdestination, true);
if (opts.logdest != old_dest)
{
@@ -1864,7 +1888,7 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
Oid relid = InvalidOid;
values[Anum_pg_subscription_sublogdestination - 1] =
- CStringGetTextDatum(ConflictLogDestLabels[opts.logdest]);
+ ConflictLogDestFlagsToArray(opts.logdest);
replaces[Anum_pg_subscription_sublogdestination - 1] = true;
update_relid = AlterSubscriptionConflictLogDestination(sub, opts.logdest, &relid);
if (update_relid)
@@ -3544,23 +3568,38 @@ GetConflictLogTableName(char *dest, Oid subid)
/*
* GetLogDestination
*
- * Convert string to enum by comparing against standardized labels.
+ * Convert log destination List of strings to enums.
*/
ConflictLogDest
-GetLogDestination(const char *dest)
+GetLogDestination(List *destlist, bool strnodelist)
{
- /* Empty string or NULL defaults to LOG. */
- if (dest == NULL || dest[0] == '\0')
+ ConflictLogDest logdest = CONFLICT_LOG_DEST_INVALID;
+ ListCell *cell;
+
+ if (destlist == NIL)
return CONFLICT_LOG_DEST_LOG;
- for (int i = CONFLICT_LOG_DEST_LOG; i <= CONFLICT_LOG_DEST_ALL; i++)
+ foreach(cell, destlist)
{
- if (pg_strcasecmp(dest, ConflictLogDestLabels[i]) == 0)
- return (ConflictLogDest) i;
+ char *name;
+
+ name = (strnodelist) ? strVal(lfirst(cell)) : (char *) lfirst(cell);
+
+ if (pg_strcasecmp(name, "log") == 0)
+ logdest |= CONFLICT_LOG_DEST_LOG;
+ else if (pg_strcasecmp(name, "table") == 0)
+ logdest |= CONFLICT_LOG_DEST_TABLE;
+ else if (pg_strcasecmp(name, "all") == 0)
+ logdest |= CONFLICT_LOG_DEST_ALL;
+ else
+ ereport(ERROR,
+ errcode(ERRCODE_SYNTAX_ERROR),
+ errmsg("unrecognized value for subscription parameter \"%s\": \"%s\"",
+ "conflict_log_destination", name),
+ errhint("Valid values are \"log\", \"table\", and \"all\"."));
}
- /* Unrecognized string. */
- return CONFLICT_LOG_DEST_INVALID;
+ return logdest;
}
/*
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 0b6e3f4a2c8..1c7ac2da6f5 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -159,8 +159,8 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
/* Insert to table if destination is 'table' or 'all' */
if (conflictlogrel)
{
- Assert(dest == CONFLICT_LOG_DEST_TABLE ||
- dest == CONFLICT_LOG_DEST_ALL);
+ Assert(CONFLICT_LOG_DEST_TABLE_ENABLED(dest) ||
+ CONFLICT_LOG_DEST_ALL_ENABLED(dest));
if (ValidateConflictLogTable(conflictlogrel))
{
@@ -187,7 +187,8 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
pgstat_report_subscription_conflict(MySubscription->oid, type);
/* Decide what detail to show in server logs. */
- if (dest == CONFLICT_LOG_DEST_LOG || dest == CONFLICT_LOG_DEST_ALL)
+ if (CONFLICT_LOG_DEST_LOG_ENABLED(dest) ||
+ CONFLICT_LOG_DEST_ALL_ENABLED(dest))
{
/* Standard reporting with full internal details. */
ereport(elevel,
@@ -263,7 +264,7 @@ GetConflictLogTableInfo(ConflictLogDest *log_dest)
* Convert the text log destination to the internal enum. MySubscription
* already contains the data from pg_subscription.
*/
- *log_dest = GetLogDestination(MySubscription->logdestination);
+ *log_dest = GetLogDestination(MySubscription->logdestination, true);
conflictlogrelid = MySubscription->conflictrelid;
/* If destination is 'log' only, no table to open. */
diff --git a/src/bin/pg_dump/pg_dump.c b/src/bin/pg_dump/pg_dump.c
index d2477cfb5a1..85b2f3a9a47 100644
--- a/src/bin/pg_dump/pg_dump.c
+++ b/src/bin/pg_dump/pg_dump.c
@@ -5522,10 +5522,10 @@ dumpSubscription(Archive *fout, const SubscriptionInfo *subinfo)
DumpOptions *dopt = fout->dopt;
PQExpBuffer delq;
PQExpBuffer query;
- PQExpBuffer publications;
+ PQExpBuffer namebuf;
char *qsubname;
- char **pubnames = NULL;
- int npubnames = 0;
+ char **names = NULL;
+ int nnames = 0;
int i;
/* Do nothing if not dumping schema */
@@ -5545,19 +5545,22 @@ dumpSubscription(Archive *fout, const SubscriptionInfo *subinfo)
appendStringLiteralAH(query, subinfo->subconninfo, fout);
/* Build list of quoted publications and append them to query. */
- if (!parsePGArray(subinfo->subpublications, &pubnames, &npubnames))
+ if (!parsePGArray(subinfo->subpublications, &names, &nnames))
pg_fatal("could not parse %s array", "subpublications");
- publications = createPQExpBuffer();
- for (i = 0; i < npubnames; i++)
+ namebuf = createPQExpBuffer();
+ for (i = 0; i < nnames; i++)
{
if (i > 0)
- appendPQExpBufferStr(publications, ", ");
+ appendPQExpBufferStr(namebuf, ", ");
- appendPQExpBufferStr(publications, fmtId(pubnames[i]));
+ appendPQExpBufferStr(namebuf, fmtId(names[i]));
}
- appendPQExpBuffer(query, " PUBLICATION %s WITH (connect = false, slot_name = ", publications->data);
+ appendPQExpBuffer(query, " PUBLICATION %s WITH (connect = false, slot_name = ", namebuf->data);
+ resetPQExpBuffer(namebuf);
+ free(names);
+
if (subinfo->subslotname)
appendStringLiteralAH(query, subinfo->subslotname, fout);
else
@@ -5610,11 +5613,22 @@ dumpSubscription(Archive *fout, const SubscriptionInfo *subinfo)
tableInfo->dobj.namespace->dobj.name);
}
+ /* Build list of quoted conflict log destinations and append them to query. */
+ if (!parsePGArray(subinfo->sublogdestination, &names, &nnames))
+ pg_fatal("could not parse %s array", "conflict_log_destination");
+
+ for (i = 0; i < nnames; i++)
+ {
+ if (i > 0)
+ appendPQExpBufferStr(namebuf, ", ");
+
+ appendPQExpBuffer(namebuf, "%s", names[i]);
+ }
+
appendPQExpBuffer(query,
- "\n\nALTER SUBSCRIPTION %s SET (conflict_log_destination = %s);\n",
+ "\n\nALTER SUBSCRIPTION %s SET (conflict_log_destination = '%s');\n",
qsubname,
- subinfo->sublogdestination);
-
+ namebuf->data);
if (subinfo->subconflictlogrelid)
appendPQExpBufferStr(query, "\n\nSELECT pg_catalog.set_config('search_path', '', false);\n");
@@ -5675,8 +5689,8 @@ dumpSubscription(Archive *fout, const SubscriptionInfo *subinfo)
NULL, subinfo->rolname,
subinfo->dobj.catId, 0, subinfo->dobj.dumpId);
- destroyPQExpBuffer(publications);
- free(pubnames);
+ destroyPQExpBuffer(namebuf);
+ free(names);
destroyPQExpBuffer(delq);
destroyPQExpBuffer(query);
diff --git a/src/bin/pg_dump/t/002_pg_dump.pl b/src/bin/pg_dump/t/002_pg_dump.pl
index 8ec7b0069dd..e0859dddf4f 100644
--- a/src/bin/pg_dump/t/002_pg_dump.pl
+++ b/src/bin/pg_dump/t/002_pg_dump.pl
@@ -3204,11 +3204,11 @@ my %tests = (
create_order => 50,
create_sql => 'CREATE SUBSCRIPTION sub3
CONNECTION \'dbname=doesnotexist\' PUBLICATION pub1
- WITH (connect = false, origin = any, streaming = on, conflict_log_destination= table);',
+ WITH (connect = false, origin = any, streaming = on, conflict_log_destination= \'log,table\');',
regexp => qr/^
\QCREATE SUBSCRIPTION sub3 CONNECTION 'dbname=doesnotexist' PUBLICATION pub1 WITH (connect = false, slot_name = 'sub3', streaming = on);\E\n\n\n
\QSELECT pg_catalog.set_config('search_path', 'public', false);\E\n\n\n
- \QALTER SUBSCRIPTION sub3 SET (conflict_log_destination = table);\E\n\n\n
+ \QALTER SUBSCRIPTION sub3 SET (conflict_log_destination = 'log, table');\E\n\n\n
\QSELECT pg_catalog.set_config('search_path', '', false);\E
/xm,
like => { %full_runs, section_post_data => 1, },
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index 46c446eaf8b..b3b2c29b96d 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -110,7 +110,7 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
* table - internal table only,
* all - both log and table.
*/
- text sublogdestination;
+ text sublogdestination[1] BKI_FORCE_NULL;
/* Only publish data originating from the specified origin */
text suborigin BKI_DEFAULT(LOGICALREP_ORIGIN_ANY);
@@ -169,7 +169,7 @@ typedef struct Subscription
List *publications; /* List of publication names to subscribe to */
char *origin; /* Only publish data originating from the
* specified origin */
- char *logdestination; /* Conflict log destination */
+ List *logdestination; /* Conflict log destination */
} Subscription;
#ifdef EXPOSE_TO_CLIENT_CODE
diff --git a/src/include/commands/subscriptioncmds.h b/src/include/commands/subscriptioncmds.h
index 255e1e241b8..aa0bc503847 100644
--- a/src/include/commands/subscriptioncmds.h
+++ b/src/include/commands/subscriptioncmds.h
@@ -38,7 +38,7 @@ extern void CheckSubDeadTupleRetention(bool check_guc, bool sub_disabled,
bool max_retention_set);
extern void GetConflictLogTableName(char *dest, Oid subid);
-extern ConflictLogDest GetLogDestination(const char *dest);
+extern ConflictLogDest GetLogDestination(List *destlist, bool strnodelist);
extern bool IsConflictLogTable(Oid relid);
#endif /* SUBSCRIPTIONCMDS_H */
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index 5f313b7a976..92b7e619eb8 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -89,19 +89,20 @@ typedef struct ConflictTupleInfo
typedef enum ConflictLogDest
{
CONFLICT_LOG_DEST_INVALID = 0,
- CONFLICT_LOG_DEST_LOG, /* "log" (default) */
- CONFLICT_LOG_DEST_TABLE, /* "table" */
- CONFLICT_LOG_DEST_ALL /* "all" */
+ CONFLICT_LOG_DEST_LOG = 1 << 0, /* 0x00000001 */
+ CONFLICT_LOG_DEST_TABLE = 1 << 1, /* 0x00000002 */
+ CONFLICT_LOG_DEST_ALL = 1 << 2 /* 0x00000004 */
} ConflictLogDest;
-/*
- * Array mapping for converting internal enum to string.
- */
-static const char *const ConflictLogDestLabels[] = {
- [CONFLICT_LOG_DEST_LOG] = "log",
- [CONFLICT_LOG_DEST_TABLE] = "table",
- [CONFLICT_LOG_DEST_ALL] = "all"
-};
+/* Conflict log destination flags */
+#define CONFLICT_LOG_DEST_LOG_ENABLED(dest) \
+ ((dest) & CONFLICT_LOG_DEST_LOG)
+
+#define CONFLICT_LOG_DEST_TABLE_ENABLED(dest) \
+ ((dest) & CONFLICT_LOG_DEST_TABLE)
+
+#define CONFLICT_LOG_DEST_ALL_ENABLED(dest) \
+ ((dest) & CONFLICT_LOG_DEST_ALL)
/* Structure to hold metadata for one column of the conflict log table */
typedef struct ConflictLogColumnDef
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index 92423c83197..471821167a0 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -119,7 +119,7 @@ HINT: To initiate replication, you must manually create the replication slot, e
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub4 SET (origin = any);
@@ -127,7 +127,7 @@ ALTER SUBSCRIPTION regress_testsub4 SET (origin = any);
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
DROP SUBSCRIPTION regress_testsub3;
@@ -148,7 +148,7 @@ ERROR: invalid connection string syntax: missing "=" after "foobar" in connecti
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET PUBLICATION testpub2, testpub3 WITH (refresh = false);
@@ -160,7 +160,7 @@ ALTER SUBSCRIPTION regress_testsub SET (run_as_owner = true);
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (password_required = true);
@@ -179,7 +179,7 @@ ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/12345');
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345 | log
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345 | {log}
(1 row)
-- ok - with lsn = NONE
@@ -191,7 +191,7 @@ ERROR: invalid WAL location (LSN): 0/0
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | {log}
(1 row)
BEGIN;
@@ -226,7 +226,7 @@ HINT: Available values: local, remote_write, remote_apply, on, off.
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
---------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
- regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000 | log
+ regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000 | {log}
(1 row)
-- rename back to keep the rest simple
@@ -258,7 +258,7 @@ HINT: To initiate replication, you must manually create the replication slot, e
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (binary = false);
@@ -267,7 +267,7 @@ ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -282,7 +282,7 @@ HINT: To initiate replication, you must manually create the replication slot, e
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = parallel);
@@ -290,7 +290,7 @@ ALTER SUBSCRIPTION regress_testsub SET (streaming = parallel);
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = false);
@@ -299,7 +299,7 @@ ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
-- fail - publication already exists
@@ -317,7 +317,7 @@ ERROR: publication "testpub1" is already in subscription "regress_testsub"
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
-- fail - publication used more than once
@@ -335,7 +335,7 @@ ALTER SUBSCRIPTION regress_testsub DROP PUBLICATION testpub1, testpub2 WITH (ref
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -374,7 +374,7 @@ HINT: To initiate replication, you must manually create the replication slot, e
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
-- we can alter streaming when two_phase enabled
@@ -383,7 +383,7 @@ ALTER SUBSCRIPTION regress_testsub SET (streaming = true);
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -396,7 +396,7 @@ HINT: To initiate replication, you must manually create the replication slot, e
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -412,7 +412,7 @@ HINT: To initiate replication, you must manually create the replication slot, e
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (disable_on_error = true);
@@ -420,7 +420,7 @@ ALTER SUBSCRIPTION regress_testsub SET (disable_on_error = true);
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -436,7 +436,7 @@ HINT: To initiate replication, you must manually create the replication slot, e
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -453,7 +453,7 @@ HINT: To initiate replication, you must manually create the replication slot, e
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
-- ok
@@ -462,7 +462,7 @@ ALTER SUBSCRIPTION regress_testsub SET (max_retention_duration = 0);
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -523,7 +523,7 @@ DROP SUBSCRIPTION regress_testsub;
SET SESSION AUTHORIZATION 'regress_subscription_user';
-- fail - unrecognized format value
CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
-ERROR: unrecognized conflict_log_destination value: "invalid"
+ERROR: unrecognized value for subscription parameter "conflict_log_destination": "invalid"
HINT: Valid values are "log", "table", and "all".
-- verify sublogdestination is 'log' and relid is 0 (InvalidOid) for default case
CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
@@ -533,7 +533,7 @@ SELECT subname, sublogdestination, subconflictlogrelid
FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
subname | sublogdestination | subconflictlogrelid
------------------------------+-------------------+---------------------
- regress_conflict_log_default | log | 0
+ regress_conflict_log_default | {log} | 0
(1 row)
-- verify empty string defaults to 'log'
@@ -544,11 +544,11 @@ SELECT subname, sublogdestination, subconflictlogrelid
FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
subname | sublogdestination | subconflictlogrelid
----------------------------+-------------------+---------------------
- regress_conflict_empty_str | log | 0
+ regress_conflict_empty_str | {log} | 0
(1 row)
-- this should generate an internal table named conflict_log_table_$subid$
-CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log, table');
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
-- check metadata in pg_subscription: destination should be 'table' and relid valid
@@ -556,7 +556,7 @@ SELECT subname, sublogdestination, subconflictlogrelid > 0 AS has_relid
FROM pg_subscription WHERE subname = 'regress_conflict_test1';
subname | sublogdestination | has_relid
------------------------+-------------------+-----------
- regress_conflict_test1 | table | t
+ regress_conflict_test1 | {log,table} | t
(1 row)
-- verify the physical table exists and its OID matches subconflictlogrelid
@@ -581,17 +581,27 @@ WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0;
(1 row)
-- ALTER: State transitions
--- transition from 'log' to 'all'
+-- transition from 'log' to 'log, table'
CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log, table');
+-- verify metadata after ALTER (destination should be 'log, table')
+SELECT subname, sublogdestination, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | sublogdestination | has_relid
+------------------------+-------------------+-----------
+ regress_conflict_test2 | {log,table} | t
+(1 row)
+
+-- transition from 'log, table' to 'all'
ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
-- verify metadata after ALTER (destination should be 'all')
SELECT subname, sublogdestination, subconflictlogrelid > 0 AS has_relid
FROM pg_subscription WHERE subname = 'regress_conflict_test2';
subname | sublogdestination | has_relid
------------------------+-------------------+-----------
- regress_conflict_test2 | all | t
+ regress_conflict_test2 | {all} | t
(1 row)
-- transition from 'all' to 'table' (should NOT drop the table, only change destination string)
@@ -601,7 +611,7 @@ SELECT sublogdestination, subconflictlogrelid = :old_relid AS relid_unchanged
FROM pg_subscription WHERE subname = 'regress_conflict_test2';
sublogdestination | relid_unchanged
-------------------+-----------------
- table | t
+ {table} | t
(1 row)
-- transition from 'table' to 'log' (should drop the table and clear relid)
@@ -610,7 +620,7 @@ SELECT sublogdestination, subconflictlogrelid
FROM pg_subscription WHERE subname = 'regress_conflict_test2';
sublogdestination | subconflictlogrelid
-------------------+---------------------
- log | 0
+ {log} | 0
(1 row)
-- verify the physical table is gone
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index b4b98c9a178..b437c383a4e 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -385,7 +385,7 @@ SELECT subname, sublogdestination, subconflictlogrelid
FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
-- this should generate an internal table named conflict_log_table_$subid$
-CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log, table');
-- check metadata in pg_subscription: destination should be 'table' and relid valid
SELECT subname, sublogdestination, subconflictlogrelid > 0 AS has_relid
@@ -405,8 +405,15 @@ JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0;
-- ALTER: State transitions
--- transition from 'log' to 'all'
+-- transition from 'log' to 'log, table'
CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log, table');
+
+-- verify metadata after ALTER (destination should be 'log, table')
+SELECT subname, sublogdestination, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'log, table' to 'all'
ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
-- verify metadata after ALTER (destination should be 'all')
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 21826be5bd7..65ba5074331 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -501,8 +501,9 @@ ConditionalStack
ConfigData
ConfigVariable
ConflictLogColumnDef
+ConflictLogDest
ConflictTupleInfo
-ConflictType
+ConflictTyp
ConnCacheEntry
ConnCacheKey
ConnParams
--
2.43.0
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-25 07:40 Dilip Kumar <dilipbalaut@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 6 replies; 912+ messages in thread
From: Dilip Kumar @ 2025-12-25 07:40 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; vignesh C <vignesh21@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Wed, Dec 24, 2025 at 4:02 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> On Tue, Dec 23, 2025 at 5:52 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Tue, Dec 23, 2025 at 5:18 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > >
> > > On Tue, Dec 23, 2025 at 10:55 AM shveta malik <shveta.malik@gmail.com> wrote:
> > > >
> > > > On Mon, Dec 22, 2025 at 9:11 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > > >
> > > > > On Mon, Dec 22, 2025 at 3:09 PM shveta malik <shveta.malik@gmail.com> wrote:
> > > > >
> > > > > I think this needs more thought, others can be fixed.
> > > > >
> > > > > > 2)
> > > > > > postgres=# drop schema shveta cascade;
> > > > > > NOTICE: drop cascades to subscription sub1
> > > > > > ERROR: global objects cannot be deleted by doDeletion
> > > > > >
> > > > > > Is this expected? Is the user supposed to see this error?
> > > > > >
> > > > > See below code, so this says if the object being dropped is the
> > > > > outermost object (i.e. if we are dropping the table directly) then it
> > > > > will disallow dropping the object on which it has INTERNAL DEPENDENCY,
> > > > > OTOH if the object is being dropped via recursive drop (i.e. the table
> > > > > is being dropped while dropping the schema) then object on which it
> > > > > has INTERNAL dependency will also be added to the deletion list and
> > > > > later will be dropped via doDeletion and later we are getting error as
> > > > > subscription is a global object. I thought maybe we can handle an
> > > > > additional case that the INTERNAL DEPENDENCY, is on subscription the
> > > > > disallow dropping it irrespective of whether it is being called
> > > > > directly or via recursive drop but then it will give an issue even
> > > > > when we are trying to drop table during subscription drop, we can make
> > > > > handle this case as well via 'flags' passed in findDependentObjects()
> > > > > but need more investigation.
> > > > >
> > > > > Seeing this complexity makes me think more on is it really worth it to
> > > > > maintain this dependency? Because during subscription drop we anyway
> > > > > have to call performDeletion externally because this dependency is
> > > > > local so we are just disallowing the conflict table drop, however the
> > > > > ALTER table is allowed so what we are really protecting by protecting
> > > > > the table drop, I think it can be just documented that if user try to
> > > > > drop the table then conflict will not be inserted anymore?
> > > > >
> > > > > findDependentObjects()
> > > > > {
> > > > > ...
> > > > > switch (foundDep->deptype)
> > > > > {
> > > > > ....
> > > > > case DEPENDENCY_INTERNAL:
> > > > > * 1. At the outermost recursion level, we must disallow the
> > > > > * DROP. However, if the owning object is listed in
> > > > > * pendingObjects, just release the caller's lock and return;
> > > > > * we'll eventually complete the DROP when we reach that entry
> > > > > * in the pending list.
> > > > > }
> > > > > }
> > > > >
> > > > > [1]
> > > > > postgres[1333899]=# select * from pg_depend where objid > 16410;
> > > > > classid | objid | objsubid | refclassid | refobjid | refobjsubid | deptype
> > > > > ---------+-------+----------+------------+----------+-------------+---------
> > > > > 1259 | 16420 | 0 | 2615 | 16410 | 0 | n
> > > > > 1259 | 16420 | 0 | 6100 | 16419 | 0 | i
> > > > > (4 rows)
> > > > >
> > > > > 16420 -> conflict_log_table_16419
> > > > > 16419 -> subscription
> > > > > 16410 -> schema s1
> > > > >
> > > >
> > > > One approach could be to use something similar to
> > > > PERFORM_DELETION_SKIP_EXTENSIONS in our case, but only for recursive
> > > > drops. The effect would be that 'DROP SCHEMA ... CASCADE' would
> > > > proceed without error, i.e., it would drop the tables as well without
> > > > including the subscription in the dependency list. But if we try to
> > > > drop a table directly (e.g., DROP TABLE CLT), it will still result in:
> > > > ERROR: cannot drop table because subscription sub1 requires it
> > > >
> > >
> > > I think this way of allowing dropping the conflict table without
> > > caring for the parent object (subscription) is not a good idea. How
> > > about creating a dedicated schema, say pg_conflict for the purpose of
> > > storing conflict tables? This will be similar to the pg_toast schema
> > > for toast tables. So, similar to that each database will have a
> > > pg_conflict schema. It prevents the "orphan" problem where a user
> > > accidentally drops the logging schema but the Subscription is still
> > > trying to write to it. pg_dump needs to ignore all system schemas
> > > EXCEPT pg_conflict. This ensures the history is preserved during
> > > migrations while still protecting the tables from accidental user
> > > deletion. About permissions, I think we need to set the schema
> > > permissions so that USAGE is public (so users can SELECT from their
> > > logs) but CREATE is restricted to the superuser/subscription owner. We
> > > may need to think some more about permissions.
> > >
> > > I also tried to reason out if we can allow storing the conflict table
> > > in pg_catalog but here are a few reasons why it won't be a good idea.
> > > I think by default, pg_dump completely ignores the pg_catalog schema.
> > > It assumes pg_catalog contains static system definitions (like
> > > pg_class, pg_proc, etc.) that are re-generated by the initdb process,
> > > not user data. If we place a conflict table in pg_catalog, it will not
> > > be backed up. If a user runs pg_dump/all to migrate to a new server,
> > > their subscription definition will survive, but their entire history
> > > of conflict logs will vanish. Also from the permissions angle, If a
> > > user wants to write a custom PL/pgSQL function to "retry" conflicts,
> > > they might need to DELETE rows from the conflict table after fixing
> > > them. Granting DELETE permissions on a table inside pg_catalog is
> > > non-standard and often frowned upon by security auditors. It blurs the
> > > line between "System Internals" (immutable) and "User Data" (mutable).
> > > So, in short a separate pg_conflict schema appears to be a better solution.
> >
> > Yeah that makes sense. Although I haven't thought about all cases
> > whether it can be a problem anywhere, but meanwhile I tried
> > prototyping with this and it behaves what we want.
> >
> > postgres[1651968]=# select * from pg_conflict.conflict_log_table_16406 ;
> > relid | schemaname | relname | conflict_type | remote_xid |
> > remote_commit_lsn | remote_commit_ts | remote_origin |
> > replica_identity | remote_tuple
> > |
> > local_conflicts
> > -------+------------+---------+-----------------------+------------+-------------------+-------------------------------+---------------+------------------+----------------
> > +------------------------------------------------------------------------------------------------------------------------------------
> > 16385 | public | test | update_origin_differs | 761 |
> > 0/01760BD8 | 2025-12-23 11:08:30.583816+00 | pg_16406 |
> > {"a":1} | {"a":1,"b":20}
> > | {"{\"xid\":\"772\",\"commit_ts\":\"2025-12-23T11:08:25.568561+00:00\",\"origin\":null,\"key\":null,\"tuple\":{\"a\":1,\"b\":10}}"}
> > (1 row)
> >
> > -- Case1: Alter is not allowed
> > postgres[1651968]=# ALTER TABLE pg_conflict.conflict_log_table_16406
> > ADD COLUMN a int;
> > ERROR: 42501: permission denied: "conflict_log_table_16406" is a system catalog
> > LOCATION: RangeVarCallbackForAlterRelation, tablecmds.c:19634
> >
>
> How was this achieved? Did you modify IsSystemClass to behave
> similarly to IsToastClass?
Right
> I tried to analyze whether there are alternative approaches. The
> possible options I see are:
>
> 1)
> heap_create_with_catalog() provides the boolean argument use_user_acl,
> which is meant to apply user-defined default privileges. In theory, we
> could predefine default ACLs for our schema and then invoke
> heap_create_with_catalog() with use_user_acl = true. But it’s not
> clear how to do this purely from internal code. We would need to mimic
> or reuse the logic behind SetDefaultACLsInSchemas.
> 2)
> Another option is to create the table using heap_create_with_catalog()
> with use_user_acl = false, and then explicitly update pg_class.relacl
> for that table, similar to what ExecGrant_Relation does when
> processing GRANT/REVOKE. But I couldn’t find any existing internal
> code paths (outside of the GRANT/REVOKE implementation itself) that do
> this kind of post-creation ACL manipulation.
I haven't analyzed this options, I will do that but not before Jan 3rd
as I will be away from my laptop for a week.
> So overall, I feel changing IsSystemClass is the simpler way right
> now. To set ACL before/after/during heap_create_with_catalog is a
> tricky thing, at-least I could not find an easier way to do this,
> unless I have missed something.
> Thoughts on possible approaches?
Here is the patches I have changed by using IsSystemClass(), based on
this many other things changed like we don't need to check for the
temp schema and also the caller of create_conflict_log_table() now
don't need to find the creation schema so it don't need to generate
the relname so that part is also moved within
create_conflict_log_table(). Fixed most of the comments given by
Peter and Shveta, although some of them are still open e.g. the name
of the conflict log table as of now I have kept as
conflict_log_table_<subid> other options are
1. pg_conflict_<subid>
2. conflict_log_<subid>
3. sub_conflict_log_<subid>
I prefer 3, considering it says this table holds subscription conflict
logs. Thoughts?
Vignesh, your patches have to be rebased on the new version.
--
Regards,
Dilip Kumar
Google
Attachments:
[application/octet-stream] v16-0001-Add-configurable-conflict-log-table-for-Logical-.patch (99.4K, ../../CAFiTN-u+_mFj9caYYFO7=_YHFXk5y=vvOm2H2=5hctYktmAVGA@mail.gmail.com/2-v16-0001-Add-configurable-conflict-log-table-for-Logical-.patch)
download | inline diff:
From 2d35e183c63d2cffea3f0eeb7f78e694d67fea34 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 17 Dec 2025 11:53:47 +0530
Subject: [PATCH v16 1/3] Add configurable conflict log table for Logical
Replication
This patch adds a feature to provide a structured, queryable record of all
logical replication conflicts. The current approach of logging conflicts as
plain text in the server logs makes it difficult to query, analyze, and
use for external monitoring and automation.
This patch addresses these limitations by introducing a configurable
conflict_log_destination=('log'/'table'/'all') option in the CREATE SUBSCRIPTION
command.
If the user chooses to enable logging to a table (by selecting 'table' or 'all'),
an internal logging table named conflict_log_table_<subid> is automatically
created within a dedicated, system-managed namespace named pg_conflict. This table
is linked to the subscription via an internal dependency, ensuring it is
automatically dropped when the subscription is removed.
The conflict details, including the original and remote tuples, are stored in JSON
columns, providing a flexible format to accommodate different table schemas.
The log table captures essential attributes such as local and remote transaction IDs,
LSNs, commit timestamps, and conflict type, providing a complete record for post-mortem
analysis.
This feature will make logical replication conflicts easier to monitor and manage,
significantly improving the overall resilience and operability of replication setups.
The conflict log tables will not be included in a publication, even if the publication
is configured to include ALL TABLES or ALL TABLES IN SCHEMA.
---
src/backend/catalog/catalog.c | 15 +-
src/backend/catalog/pg_publication.c | 26 +-
src/backend/catalog/pg_subscription.c | 7 +
src/backend/commands/subscriptioncmds.c | 288 +++++++++++++++++-
src/bin/psql/describe.c | 21 +-
src/bin/psql/tab-complete.in.c | 8 +-
src/include/catalog/catalog.h | 1 +
src/include/catalog/pg_namespace.dat | 3 +
src/include/catalog/pg_subscription.h | 11 +
src/include/commands/subscriptioncmds.h | 4 +
src/include/replication/conflict.h | 56 ++++
src/test/regress/expected/subscription.out | 336 +++++++++++++++------
src/test/regress/sql/subscription.sql | 118 ++++++++
src/tools/pgindent/typedefs.list | 2 +
14 files changed, 791 insertions(+), 105 deletions(-)
diff --git a/src/backend/catalog/catalog.c b/src/backend/catalog/catalog.c
index 59caae8f1bc..e5f57d4aa69 100644
--- a/src/backend/catalog/catalog.c
+++ b/src/backend/catalog/catalog.c
@@ -86,7 +86,8 @@ bool
IsSystemClass(Oid relid, Form_pg_class reltuple)
{
/* IsCatalogRelationOid is a bit faster, so test that first */
- return (IsCatalogRelationOid(relid) || IsToastClass(reltuple));
+ return (IsCatalogRelationOid(relid) || IsToastClass(reltuple)
+ || IsConflictClass(reltuple));
}
/*
@@ -230,6 +231,18 @@ IsToastClass(Form_pg_class reltuple)
return IsToastNamespace(relnamespace);
}
+/*
+ * IsConflictClass - Check if the given pg_class tuple belongs to the conflict
+ * namespace.
+ */
+bool
+IsConflictClass(Form_pg_class reltuple)
+{
+ Oid relnamespace = reltuple->relnamespace;
+
+ return (relnamespace == PG_CONFLICT_NAMESPACE);
+}
+
/*
* IsCatalogNamespace
* True iff namespace is pg_catalog.
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index 7aa3f179924..9f84e02b7ef 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -31,6 +31,7 @@
#include "catalog/pg_publication_rel.h"
#include "catalog/pg_type.h"
#include "commands/publicationcmds.h"
+#include "commands/subscriptioncmds.h"
#include "funcapi.h"
#include "utils/array.h"
#include "utils/builtins.h"
@@ -85,6 +86,15 @@ check_publication_add_relation(Relation targetrel)
errmsg("cannot add relation \"%s\" to publication",
RelationGetRelationName(targetrel)),
errdetail("This operation is not supported for unlogged tables.")));
+
+ /* Can't be conflict log table */
+ if (IsConflictLogTable(RelationGetRelid(targetrel)))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot add relation \"%s.%s\" to publication",
+ get_namespace_name(RelationGetNamespace(targetrel)),
+ RelationGetRelationName(targetrel)),
+ errdetail("This operation is not supported for conflict log tables.")));
}
/*
@@ -145,6 +155,13 @@ is_publishable_class(Oid relid, Form_pg_class reltuple)
/*
* Another variant of is_publishable_class(), taking a Relation.
+ *
+ * Note: Conflict log tables are not publishable. However, we intentionally
+ * skip this check here because this function is called for every change and
+ * performing this check during every change publication is costly. To ensure
+ * unpublishable entries are ignored without incurring performance overhead,
+ * tuples inserted into the conflict log table uses the HEAP_INSERT_NO_LOGICAL
+ * flag. This allows the decoding layer to bypass these entries automatically.
*/
bool
is_publishable_relation(Relation rel)
@@ -169,7 +186,10 @@ pg_relation_is_publishable(PG_FUNCTION_ARGS)
tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
if (!HeapTupleIsValid(tuple))
PG_RETURN_NULL();
- result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple));
+
+ /* Subscription conflict log tables are not published */
+ result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple)) &&
+ !IsConflictLogTable(relid);
ReleaseSysCache(tuple);
PG_RETURN_BOOL(result);
}
@@ -890,7 +910,9 @@ GetAllPublicationRelations(char relkind, bool pubviaroot)
Form_pg_class relForm = (Form_pg_class) GETSTRUCT(tuple);
Oid relid = relForm->oid;
+ /* Subscription conflict log tables are not published */
if (is_publishable_class(relid, relForm) &&
+ !IsConflictLogTable(relid) &&
!(relForm->relispartition && pubviaroot))
result = lappend_oid(result, relid);
}
@@ -1018,7 +1040,7 @@ GetSchemaPublicationRelations(Oid schemaid, PublicationPartOpt pub_partopt)
Oid relid = relForm->oid;
char relkind;
- if (!is_publishable_class(relid, relForm))
+ if (!is_publishable_class(relid, relForm) || IsConflictLogTable(relid))
continue;
relkind = get_rel_relkind(relid);
diff --git a/src/backend/catalog/pg_subscription.c b/src/backend/catalog/pg_subscription.c
index ad6fbd77ffd..842cc417e68 100644
--- a/src/backend/catalog/pg_subscription.c
+++ b/src/backend/catalog/pg_subscription.c
@@ -106,6 +106,7 @@ GetSubscription(Oid subid, bool missing_ok)
sub->retaindeadtuples = subform->subretaindeadtuples;
sub->maxretention = subform->submaxretention;
sub->retentionactive = subform->subretentionactive;
+ sub->conflictlogrelid = subform->subconflictlogrelid;
/* Get conninfo */
datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
@@ -141,6 +142,12 @@ GetSubscription(Oid subid, bool missing_ok)
Anum_pg_subscription_suborigin);
sub->origin = TextDatumGetCString(datum);
+ /* Get conflict log destination */
+ datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
+ tup,
+ Anum_pg_subscription_subconflictlogdest);
+ sub->conflictlogdest = TextDatumGetCString(datum);
+
/* Is the subscription owner a superuser? */
sub->ownersuperuser = superuser_arg(sub->owner);
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index abbcaff0838..cf67c9fd351 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -15,25 +15,31 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/htup_details.h"
#include "access/table.h"
#include "access/twophase.h"
#include "access/xact.h"
#include "catalog/catalog.h"
#include "catalog/dependency.h"
+#include "catalog/heap.h"
#include "catalog/indexing.h"
#include "catalog/namespace.h"
#include "catalog/objectaccess.h"
#include "catalog/objectaddress.h"
+#include "catalog/pg_am_d.h"
#include "catalog/pg_authid_d.h"
#include "catalog/pg_database_d.h"
+#include "catalog/pg_namespace.h"
#include "catalog/pg_subscription.h"
#include "catalog/pg_subscription_rel.h"
#include "catalog/pg_type.h"
+#include "commands/comment.h"
#include "commands/defrem.h"
#include "commands/event_trigger.h"
#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "miscadmin.h"
#include "nodes/makefuncs.h"
#include "pgstat.h"
@@ -51,6 +57,7 @@
#include "utils/lsyscache.h"
#include "utils/memutils.h"
#include "utils/pg_lsn.h"
+#include "utils/regproc.h"
#include "utils/syscache.h"
/*
@@ -75,6 +82,7 @@
#define SUBOPT_MAX_RETENTION_DURATION 0x00008000
#define SUBOPT_LSN 0x00010000
#define SUBOPT_ORIGIN 0x00020000
+#define SUBOPT_CONFLICT_LOG_DEST 0x00040000
/* check if the 'val' has 'bits' set */
#define IsSet(val, bits) (((val) & (bits)) == (bits))
@@ -103,6 +111,7 @@ typedef struct SubOpts
bool retaindeadtuples;
int32 maxretention;
char *origin;
+ ConflictLogDest logdest;
XLogRecPtr lsn;
} SubOpts;
@@ -135,7 +144,7 @@ static List *merge_publications(List *oldpublist, List *newpublist, bool addpub,
static void ReportSlotConnectionError(List *rstates, Oid subid, char *slotname, char *err);
static void CheckAlterSubOption(Subscription *sub, const char *option,
bool slot_needs_update, bool isTopLevel);
-
+static Oid create_conflict_log_table(Oid subid, char *subname);
/*
* Common option parsing function for CREATE and ALTER SUBSCRIPTION commands.
@@ -191,6 +200,8 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->maxretention = 0;
if (IsSet(supported_opts, SUBOPT_ORIGIN))
opts->origin = pstrdup(LOGICALREP_ORIGIN_ANY);
+ if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DEST))
+ opts->logdest = CONFLICT_LOG_DEST_LOG;
/* Parse options */
foreach(lc, stmt_options)
@@ -402,6 +413,20 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->specified_opts |= SUBOPT_LSN;
opts->lsn = lsn;
}
+ else if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DEST) &&
+ strcmp(defel->defname, "conflict_log_destination") == 0)
+ {
+ char *val;
+ ConflictLogDest dest;
+
+ if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_LOG_DEST))
+ errorConflictingDefElem(defel, pstate);
+
+ val = defGetString(defel);
+ dest = GetLogDestination(val);
+ opts->logdest = dest;
+ opts->specified_opts |= SUBOPT_CONFLICT_LOG_DEST;
+ }
else
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
@@ -612,7 +637,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
SUBOPT_DISABLE_ON_ERR | SUBOPT_PASSWORD_REQUIRED |
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
- SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN);
+ SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_DEST);
parse_subscription_options(pstate, stmt->options, supported_opts, &opts);
/*
@@ -747,6 +773,31 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
values[Anum_pg_subscription_suborigin - 1] =
CStringGetTextDatum(opts.origin);
+ /* Always set the destination, default will be 'log'. */
+ values[Anum_pg_subscription_subconflictlogdest - 1] =
+ CStringGetTextDatum(ConflictLogDestNames[opts.logdest]);
+
+ /*
+ * If logging to a table is required, physically create the logging
+ * relation and store its OID in the catalog.
+ */
+ if (opts.logdest == CONFLICT_LOG_DEST_TABLE ||
+ opts.logdest == CONFLICT_LOG_DEST_ALL)
+ {
+ Oid logrelid;
+
+ /* Store the Oid returned from creation. */
+ logrelid = create_conflict_log_table(subid, stmt->subname);
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(logrelid);
+ }
+ else
+ {
+ /* Destination is 'log'; no table is needed. */
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(InvalidOid);
+ }
+
tup = heap_form_tuple(RelationGetDescr(rel), values, nulls);
/* Insert tuple into catalog. */
@@ -1410,7 +1461,8 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
SUBOPT_MAX_RETENTION_DURATION |
- SUBOPT_ORIGIN);
+ SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_DEST);
parse_subscription_options(pstate, stmt->options,
supported_opts, &opts);
@@ -1665,6 +1717,63 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
origin = opts.origin;
}
+ if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_LOG_DEST))
+ {
+ ConflictLogDest old_dest =
+ GetLogDestination(sub->conflictlogdest);
+
+ if (opts.logdest != old_dest)
+ {
+ bool want_table =
+ IsSet(opts.logdest, CONFLICT_LOG_DEST_TABLE);
+ bool has_oldtable =
+ IsSet(old_dest, CONFLICT_LOG_DEST_TABLE);
+
+ values[Anum_pg_subscription_subconflictlogdest - 1] =
+ CStringGetTextDatum(ConflictLogDestNames[opts.logdest]);
+ replaces[Anum_pg_subscription_subconflictlogdest - 1] = true;
+
+ if (want_table && !has_oldtable)
+ {
+ Oid relid;
+
+ relid = create_conflict_log_table(subid, sub->name);
+
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(relid);
+ replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
+ true;
+ }
+ else if (!want_table && has_oldtable)
+ {
+ ObjectAddress object;
+
+ /*
+ * Conflict log tables are recorded as internal
+ * dependencies of the subscription. Drop the
+ * table if it is not required anymore to avoid
+ * stale or orphaned relations.
+ *
+ * XXX: At present, only conflict log tables are
+ * managed this way. In future if we introduce
+ * additional internal dependencies, we may need
+ * a targeted deletion to avoid deletion of any
+ * other objects.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId,
+ subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(InvalidOid);
+ replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
+ true;
+ }
+ }
+ }
+
update_tuple = true;
break;
}
@@ -2027,6 +2136,7 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
Form_pg_subscription form;
List *rstates;
bool must_use_password;
+ ObjectAddress object;
/*
* The launcher may concurrently start a new worker for this subscription.
@@ -2184,6 +2294,19 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
/* Clean up dependencies */
deleteSharedDependencyRecordsFor(SubscriptionRelationId, subid, 0);
+ /*
+ * Conflict log tables are recorded as internal dependencies of the
+ * subscription. We must drop the dependent objects before the
+ * subscription itself is removed. By using
+ * PERFORM_DELETION_SKIP_ORIGINAL, we ensure that only the conflict log
+ * table is reaped while the subscription remains for the final deletion
+ * step.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId, subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
/* Remove any associated relation synchronization states. */
RemoveSubscriptionRel(subid, InvalidOid);
@@ -3188,3 +3311,162 @@ defGetStreamingMode(DefElem *def)
def->defname)));
return LOGICALREP_STREAM_OFF; /* keep compiler quiet */
}
+
+/*
+ * Builds the TupleDesc for the conflict log table.
+ */
+static TupleDesc
+create_conflict_log_table_tupdesc(void)
+{
+ TupleDesc tupdesc;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_CONFLICT_ATTR_NUM);
+
+ for (int i = 0; i < MAX_CONFLICT_ATTR_NUM; i++)
+ {
+ Oid type_oid = ConflictLogSchema[i].atttypid;
+
+ /*
+ * Special handling for the JSON array type for proper
+ * TupleDescInitEntry call.
+ */
+ if (type_oid == JSONARRAYOID)
+ type_oid = get_array_type(JSONOID);
+
+ TupleDescInitEntry(tupdesc, i + 1,
+ ConflictLogSchema[i].attname,
+ type_oid,
+ -1, 0);
+ }
+
+ return BlessTupleDesc(tupdesc);
+}
+
+/*
+ * Create a structured conflict log table for a subscription.
+ *
+ * The table is created within the dedicated 'pg_conflict' namespace, which
+ * is system-managed. The table name is generated automatically using the
+ * subscription's OID (e.g., "conflict_log_table_<subid>") to ensure uniqueness
+ * within the cluster and to avoid collisions during subscription renames.
+ */
+static Oid
+create_conflict_log_table(Oid subid, char *subname)
+{
+ TupleDesc tupdesc;
+ Oid relid;
+ ObjectAddress myself;
+ ObjectAddress subaddr;
+ char relname[NAMEDATALEN];
+
+ snprintf(relname, NAMEDATALEN, "conflict_log_table_%u", subid);
+
+ /* Report an error if the specified conflict log table already exists. */
+ if (OidIsValid(get_relname_relid(relname, PG_CONFLICT_NAMESPACE)))
+ ereport(ERROR,
+ (errcode(ERRCODE_DUPLICATE_TABLE),
+ errmsg("relation \"%s.%s\" already exists",
+ get_namespace_name(PG_CONFLICT_NAMESPACE), relname)));
+
+ /* Build the tuple descriptor for the new table. */
+ tupdesc = create_conflict_log_table_tupdesc();
+
+ /* Create conflict log table. */
+ relid = heap_create_with_catalog(relname,
+ PG_CONFLICT_NAMESPACE,
+ 0,
+ InvalidOid,
+ InvalidOid,
+ InvalidOid,
+ GetUserId(),
+ HEAP_TABLE_AM_OID,
+ tupdesc,
+ NIL,
+ RELKIND_RELATION,
+ RELPERSISTENCE_PERMANENT,
+ false,
+ false,
+ ONCOMMIT_NOOP,
+ (Datum) 0,
+ false,
+ false,
+ false,
+ InvalidOid,
+ NULL);
+
+ /*
+ * Establish an internal dependency between the conflict log table and
+ * the subscription. By using DEPENDENCY_INTERNAL, we ensure the table
+ * is automatically reaped when the subscription is dropped. This also
+ * prevents the table from being dropped independently unless the
+ * subscription itself is removed.
+ */
+ ObjectAddressSet(myself, RelationRelationId, relid);
+ ObjectAddressSet(subaddr, SubscriptionRelationId, subid);
+ recordDependencyOn(&myself, &subaddr, DEPENDENCY_INTERNAL);
+
+ /* Release tuple descriptor memory. */
+ FreeTupleDesc(tupdesc);
+
+ return relid;
+}
+
+/*
+ * GetLogDestination
+ *
+ * Convert string to enum by comparing against standardized labels.
+ */
+ConflictLogDest
+GetLogDestination(const char *dest)
+{
+ /* Empty string or NULL defaults to LOG. */
+ if (dest == NULL || dest[0] == '\0' || pg_strcasecmp(dest, "log") == 0)
+ return CONFLICT_LOG_DEST_LOG;
+
+ if (pg_strcasecmp(dest,
+ ConflictLogDestNames[CONFLICT_LOG_DEST_TABLE]) == 0)
+ return CONFLICT_LOG_DEST_TABLE;
+
+ if (pg_strcasecmp(dest, ConflictLogDestNames[CONFLICT_LOG_DEST_ALL]) == 0)
+ return CONFLICT_LOG_DEST_ALL;
+
+ /* Unrecognized string. */
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("unrecognized conflict_log_destination value: \"%s\"", dest),
+ errhint("Valid values are \"log\", \"table\", and \"all\".")));
+}
+
+/*
+ * Check if the specified relation is used as a conflict log table by any
+ * subscription.
+ */
+bool
+IsConflictLogTable(Oid relid)
+{
+ Relation rel;
+ TableScanDesc scan;
+ HeapTuple tup;
+ bool is_clt = false;
+
+ rel = table_open(SubscriptionRelationId, AccessShareLock);
+ scan = table_beginscan_catalog(rel, 0, NULL);
+
+ while (HeapTupleIsValid(tup = heap_getnext(scan, ForwardScanDirection)))
+ {
+ Form_pg_subscription subform = (Form_pg_subscription) GETSTRUCT(tup);
+
+ /* Direct Oid comparison from catalog */
+ if (OidIsValid(subform->subconflictlogrelid) &&
+ subform->subconflictlogrelid == relid)
+ {
+ is_clt = true;
+ break;
+ }
+ }
+
+ table_endscan(scan);
+ table_close(rel, AccessShareLock);
+
+ return is_clt;
+}
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index 36f24502842..d21e3d95506 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -6806,7 +6806,7 @@ describeSubscriptions(const char *pattern, bool verbose)
printQueryOpt myopt = pset.popt;
static const bool translate_columns[] = {false, false, false, false,
false, false, false, false, false, false, false, false, false, false,
- false, false, false, false};
+ false, false, false, false, false};
if (pset.sversion < 100000)
{
@@ -6900,15 +6900,22 @@ describeSubscriptions(const char *pattern, bool verbose)
appendPQExpBuffer(&buf,
", subskiplsn AS \"%s\"\n",
gettext_noop("Skip LSN"));
+
+ /* Conflict log destination is supported in v19 and higher */
+ if (pset.sversion >= 190000)
+ {
+ appendPQExpBuffer(&buf,
+ ", subconflictlogdest AS \"%s\"\n",
+ gettext_noop("Conflict log destination"));
+ }
}
/* Only display subscriptions in current database. */
- appendPQExpBufferStr(&buf,
- "FROM pg_catalog.pg_subscription\n"
- "WHERE subdbid = (SELECT oid\n"
- " FROM pg_catalog.pg_database\n"
- " WHERE datname = pg_catalog.current_database())");
-
+ appendPQExpBuffer(&buf,
+ "FROM pg_catalog.pg_subscription "
+ "WHERE subdbid = (SELECT oid\n"
+ " FROM pg_catalog.pg_database\n"
+ " WHERE datname = pg_catalog.current_database())");
if (!validateSQLNamePattern(&buf, pattern, true, false,
NULL, "subname", NULL,
NULL,
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index b1ff6f6cd94..fd279d98b1b 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -2344,8 +2344,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("(", "PUBLICATION");
/* ALTER SUBSCRIPTION <name> SET ( */
else if (Matches("ALTER", "SUBSCRIPTION", MatchAny, MatchAnyN, "SET", "("))
- COMPLETE_WITH("binary", "disable_on_error", "failover",
- "max_retention_duration", "origin",
+ COMPLETE_WITH("binary", "conflict_log_destination", "disable_on_error",
+ "failover", "max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
"synchronous_commit", "two_phase");
@@ -3828,8 +3828,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("WITH (");
/* Complete "CREATE SUBSCRIPTION <name> ... WITH ( <opt>" */
else if (Matches("CREATE", "SUBSCRIPTION", MatchAnyN, "WITH", "("))
- COMPLETE_WITH("binary", "connect", "copy_data", "create_slot",
- "disable_on_error", "enabled", "failover",
+ COMPLETE_WITH("binary", "conflict_log_destination", "connect", "copy_data",
+ "create_slot", "disable_on_error", "enabled", "failover",
"max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
diff --git a/src/include/catalog/catalog.h b/src/include/catalog/catalog.h
index 9b5e750b7e4..1d545ebac08 100644
--- a/src/include/catalog/catalog.h
+++ b/src/include/catalog/catalog.h
@@ -25,6 +25,7 @@ extern bool IsInplaceUpdateRelation(Relation relation);
extern bool IsSystemClass(Oid relid, Form_pg_class reltuple);
extern bool IsToastClass(Form_pg_class reltuple);
+extern bool IsConflictClass(Form_pg_class reltuple);
extern bool IsCatalogRelationOid(Oid relid);
extern bool IsCatalogTextUniqueIndexOid(Oid relid);
diff --git a/src/include/catalog/pg_namespace.dat b/src/include/catalog/pg_namespace.dat
index 008373728f3..4bb8cd92b71 100644
--- a/src/include/catalog/pg_namespace.dat
+++ b/src/include/catalog/pg_namespace.dat
@@ -18,6 +18,9 @@
{ oid => '99', oid_symbol => 'PG_TOAST_NAMESPACE',
descr => 'reserved schema for TOAST tables',
nspname => 'pg_toast', nspacl => '_null_' },
+{ oid => '1382', oid_symbol => 'PG_CONFLICT_NAMESPACE',
+ descr => 'reserved schema for conflict tables',
+ nspname => 'pg_conflict', nspacl => '_null_' },
# update dumpNamespace() if changing this descr
{ oid => '2200', oid_symbol => 'PG_PUBLIC_NAMESPACE',
descr => 'standard public schema',
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index 55cb9b1eefa..68f69c6e445 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -90,6 +90,7 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
* exceeded max_retention_duration, when
* defined */
+ Oid subconflictlogrelid; /* Relid of the conflict log table. */
#ifdef CATALOG_VARLEN /* variable-length fields start here */
/* Connection string to the publisher */
text subconninfo BKI_FORCE_NOT_NULL;
@@ -103,6 +104,14 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
/* List of publications subscribed to */
text subpublications[1] BKI_FORCE_NOT_NULL;
+ /*
+ * Strategy for logging replication conflicts:
+ * 'log' - server log only,
+ * 'table' - internal table only,
+ * 'all' - both log and table.
+ */
+ text subconflictlogdest;
+
/* Only publish data originating from the specified origin */
text suborigin BKI_DEFAULT(LOGICALREP_ORIGIN_ANY);
#endif
@@ -152,12 +161,14 @@ typedef struct Subscription
* and the retention duration has not
* exceeded max_retention_duration, when
* defined */
+ Oid conflictlogrelid; /* conflict log table Oid */
char *conninfo; /* Connection string to the publisher */
char *slotname; /* Name of the replication slot */
char *synccommit; /* Synchronous commit setting for worker */
List *publications; /* List of publication names to subscribe to */
char *origin; /* Only publish data originating from the
* specified origin */
+ char *conflictlogdest; /* Conflict log destination */
} Subscription;
#ifdef EXPOSE_TO_CLIENT_CODE
diff --git a/src/include/commands/subscriptioncmds.h b/src/include/commands/subscriptioncmds.h
index fb4e26a51a4..fc403cc4c5f 100644
--- a/src/include/commands/subscriptioncmds.h
+++ b/src/include/commands/subscriptioncmds.h
@@ -17,6 +17,7 @@
#include "catalog/objectaddress.h"
#include "parser/parse_node.h"
+#include "replication/conflict.h"
extern ObjectAddress CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bool isTopLevel);
@@ -36,4 +37,7 @@ extern void CheckSubDeadTupleRetention(bool check_guc, bool sub_disabled,
bool retention_active,
bool max_retention_set);
+extern ConflictLogDest GetLogDestination(const char *dest);
+extern bool IsConflictLogTable(Oid relid);
+
#endif /* SUBSCRIPTIONCMDS_H */
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index c8fbf9e51b8..63b68d66543 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -10,6 +10,7 @@
#define CONFLICT_H
#include "access/xlogdefs.h"
+#include "catalog/pg_type.h"
#include "nodes/pg_list.h"
#include "utils/timestamp.h"
@@ -79,6 +80,61 @@ typedef struct ConflictTupleInfo
* conflicting local row occurred */
} ConflictTupleInfo;
+/*
+ * Conflict log destination types.
+ *
+ * These values are defined as bitmask flags to allow for multiple simultaneous
+ * logging destinations (e.g., logging to both system logs and a table).
+ * Internally, we use these for bitwise comparisons (IsSet), but the string
+ * representation is stored in pg_subscription.subconflictlogdest.
+ */
+typedef enum ConflictLogDest
+{
+ /* Log conflicts to the server logs */
+ CONFLICT_LOG_DEST_LOG = 1 << 0, /* 0x01 */
+
+ /* Log conflicts to an internally managed table */
+ CONFLICT_LOG_DEST_TABLE = 1 << 1, /* 0x02 */
+
+ /* Convenience flag for all supported destinations */
+ CONFLICT_LOG_DEST_ALL = (CONFLICT_LOG_DEST_LOG | CONFLICT_LOG_DEST_TABLE)
+} ConflictLogDest;
+
+/*
+ * Array mapping for converting internal enum to string.
+ */
+static const char *const ConflictLogDestNames[] = {
+ [CONFLICT_LOG_DEST_LOG] = "log",
+ [CONFLICT_LOG_DEST_TABLE] = "table",
+ [CONFLICT_LOG_DEST_ALL] = "all"
+};
+
+/* Structure to hold metadata for one column of the conflict log table */
+typedef struct ConflictLogColumnDef
+{
+ const char *attname; /* Column name */
+ Oid atttypid; /* Data type OID */
+} ConflictLogColumnDef;
+
+/* The single source of truth for the conflict log table schema */
+static const ConflictLogColumnDef ConflictLogSchema[] =
+{
+ { .attname = "relid", .atttypid = OIDOID },
+ { .attname = "schemaname", .atttypid = TEXTOID },
+ { .attname = "relname", .atttypid = TEXTOID },
+ { .attname = "conflict_type", .atttypid = TEXTOID },
+ { .attname = "remote_xid", .atttypid = XIDOID },
+ { .attname = "remote_commit_lsn",.atttypid = LSNOID },
+ { .attname = "remote_commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "remote_origin", .atttypid = TEXTOID },
+ { .attname = "replica_identity", .atttypid = JSONOID },
+ { .attname = "remote_tuple", .atttypid = JSONOID },
+ { .attname = "local_conflicts", .atttypid = JSONARRAYOID }
+};
+
+/* Define the count using the array size */
+#define MAX_CONFLICT_ATTR_NUM (sizeof(ConflictLogSchema) / sizeof(ConflictLogSchema[0]))
+
extern bool GetTupleTransactionInfo(TupleTableSlot *localslot,
TransactionId *xmin,
RepOriginId *localorigin,
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index 327d1e7731f..60c8ef10a8a 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -116,18 +116,18 @@ CREATE SUBSCRIPTION regress_testsub4 CONNECTION 'dbname=regress_doesnotexist' PU
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub4 SET (origin = any);
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub3;
@@ -145,10 +145,10 @@ ALTER SUBSCRIPTION regress_testsub CONNECTION 'foobar';
ERROR: invalid connection string syntax: missing "=" after "foobar" in connection info string
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET PUBLICATION testpub2, testpub3 WITH (refresh = false);
@@ -157,10 +157,10 @@ ALTER SUBSCRIPTION regress_testsub SET (slot_name = 'newname');
ALTER SUBSCRIPTION regress_testsub SET (password_required = false);
ALTER SUBSCRIPTION regress_testsub SET (run_as_owner = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (password_required = true);
@@ -176,10 +176,10 @@ ERROR: unrecognized subscription parameter: "create_slot"
-- ok
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/12345');
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345 | log
(1 row)
-- ok - with lsn = NONE
@@ -188,10 +188,10 @@ ALTER SUBSCRIPTION regress_testsub SKIP (lsn = NONE);
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/0');
ERROR: invalid WAL location (LSN): 0/0
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
BEGIN;
@@ -223,10 +223,10 @@ ALTER SUBSCRIPTION regress_testsub_foo SET (synchronous_commit = foobar);
ERROR: invalid value for parameter "synchronous_commit": "foobar"
HINT: Available values: local, remote_write, remote_apply, on, off.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
----------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+---------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
-- rename back to keep the rest simple
@@ -255,19 +255,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (binary = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -279,27 +279,27 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = parallel);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- fail - publication already exists
@@ -314,10 +314,10 @@ ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refr
ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refresh = false);
ERROR: publication "testpub1" is already in subscription "regress_testsub"
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- fail - publication used more than once
@@ -332,10 +332,10 @@ ERROR: publication "testpub3" is not in subscription "regress_testsub"
-- ok - delete publications
ALTER SUBSCRIPTION regress_testsub DROP PUBLICATION testpub1, testpub2 WITH (refresh = false);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -371,19 +371,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- we can alter streaming when two_phase enabled
ALTER SUBSCRIPTION regress_testsub SET (streaming = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -393,10 +393,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -409,18 +409,18 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (disable_on_error = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -433,10 +433,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -450,19 +450,19 @@ NOTICE: max_retention_duration is ineffective when retain_dead_tuples is disabl
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- ok
ALTER SUBSCRIPTION regress_testsub SET (max_retention_duration = 0);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -517,7 +517,167 @@ COMMIT;
-- ok, owning it is enough for this stuff
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG DESTINATION TESTS
+--
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+-- fail - unrecognized parameter value
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
+ERROR: unrecognized conflict_log_destination value: "invalid"
+HINT: Valid values are "log", "table", and "all".
+-- verify subconflictlogdest is 'log' and relid is 0 (InvalidOid) for default case
+CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
+ subname | subconflictlogdest | subconflictlogrelid
+------------------------------+--------------------+---------------------
+ regress_conflict_log_default | log | 0
+(1 row)
+
+-- verify empty string defaults to 'log'
+CREATE SUBSCRIPTION regress_conflict_empty_str CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = '');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
+ subname | subconflictlogdest | subconflictlogrelid
+----------------------------+--------------------+---------------------
+ regress_conflict_empty_str | log | 0
+(1 row)
+
+-- this should generate an internal table named conflict_log_table_$subid$
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- check metadata in pg_subscription: destination should be 'table' and relid valid
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname | subconflictlogdest | has_relid
+------------------------+--------------------+-----------
+ regress_conflict_test1 | table | t
+(1 row)
+
+-- verify the physical table exists and its OID matches subconflictlogrelid
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND c.oid = s.subconflictlogrelid;
+ count
+-------
+ 1
+(1 row)
+
+-- check if the internal table has the correct schema (11 columns)
+SELECT count(*)
+FROM pg_attribute a
+JOIN pg_class c ON a.attrelid = c.oid
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0;
+ count
+-------
+ 11
+(1 row)
+
+--
+-- ALTER SUBSCRIPTION - conflict_log_destination state transitions
+--
+-- These tests verify the transition logic between different logging
+-- destinations, ensuring internal tables are created or dropped as expected.
+--
+-- transition from 'log' to 'all'
+-- a new internal conflict log table should be created
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
+-- verify metadata after ALTER (destination should be 'all')
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogdest | has_relid
+------------------------+--------------------+-----------
+ regress_conflict_test2 | all | t
+(1 row)
+
+-- transition from 'all' to 'table'
+-- should NOT drop the table, only change destination string
+SELECT subconflictlogrelid AS old_relid FROM pg_subscription WHERE subname = 'regress_conflict_test2' \gset
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'table');
+SELECT subconflictlogdest, subconflictlogrelid = :old_relid AS relid_unchanged
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subconflictlogdest | relid_unchanged
+--------------------+-----------------
+ table | t
+(1 row)
+
+-- transition from 'table' to 'log'
+-- should drop the table and clear relid
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log');
+SELECT subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subconflictlogdest | subconflictlogrelid
+--------------------+---------------------
+ log | 0
+(1 row)
+
+-- verify the physical table is gone
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test2';
+ count
+-------
+ 0
+(1 row)
+
+-- ensure drop table not allowed and DROP SUBSCRIPTION reaps the table
+-- re-enable table logging for verification
+ALTER SUBSCRIPTION regress_conflict_test1 SET (conflict_log_destination = 'table');
+-- fail - drop table not allowed due to internal dependency
+-- use DO block to hide OID in error message
+DO $$
+BEGIN
+ EXECUTE 'DROP TABLE ' || (SELECT 'pg_conflict.conflict_log_table_' || oid FROM pg_subscription WHERE subname = 'regress_conflict_test1');
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege';
+END $$;
+NOTICE: captured expected error: insufficient_privilege
+-- PUBLICATION: Verify internal tables are not publishable
+-- pg_relation_is_publishable should return false for internal conflict log tables
+SELECT pg_relation_is_publishable(subconflictlogrelid)
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ pg_relation_is_publishable
+----------------------------
+ f
+(1 row)
+
+-- CLEANUP: Proper drop reaps the table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+-- Verify the table OID for reap check
+SELECT 'conflict_log_table_' || oid AS internal_tablename FROM pg_subscription WHERE subname = 'regress_conflict_test1' \gset
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the internal table was reaped via dependency
+SELECT to_regclass(:'internal_tablename');
+ to_regclass
+-------------
+
+(1 row)
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_log_default DISABLE;
+ALTER SUBSCRIPTION regress_conflict_log_default SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_log_default;
+ALTER SUBSCRIPTION regress_conflict_empty_str DISABLE;
+ALTER SUBSCRIPTION regress_conflict_empty_str SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_empty_str;
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
RESET SESSION AUTHORIZATION;
+DROP SCHEMA clt;
+ERROR: schema "clt" does not exist
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
DROP ROLE regress_subscription_user3;
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index ef0c298d2df..00951aa8b9a 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -365,7 +365,125 @@ COMMIT;
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG DESTINATION TESTS
+--
+
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+
+-- fail - unrecognized parameter value
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
+
+-- verify subconflictlogdest is 'log' and relid is 0 (InvalidOid) for default case
+CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
+
+-- verify empty string defaults to 'log'
+CREATE SUBSCRIPTION regress_conflict_empty_str CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = '');
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
+
+-- this should generate an internal table named conflict_log_table_$subid$
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+
+-- check metadata in pg_subscription: destination should be 'table' and relid valid
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- verify the physical table exists and its OID matches subconflictlogrelid
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND c.oid = s.subconflictlogrelid;
+
+-- check if the internal table has the correct schema (11 columns)
+SELECT count(*)
+FROM pg_attribute a
+JOIN pg_class c ON a.attrelid = c.oid
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0;
+
+--
+-- ALTER SUBSCRIPTION - conflict_log_destination state transitions
+--
+-- These tests verify the transition logic between different logging
+-- destinations, ensuring internal tables are created or dropped as expected.
+--
+
+-- transition from 'log' to 'all'
+-- a new internal conflict log table should be created
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
+
+-- verify metadata after ALTER (destination should be 'all')
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'all' to 'table'
+-- should NOT drop the table, only change destination string
+SELECT subconflictlogrelid AS old_relid FROM pg_subscription WHERE subname = 'regress_conflict_test2' \gset
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'table');
+SELECT subconflictlogdest, subconflictlogrelid = :old_relid AS relid_unchanged
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'table' to 'log'
+-- should drop the table and clear relid
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log');
+SELECT subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- verify the physical table is gone
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test2';
+
+-- ensure drop table not allowed and DROP SUBSCRIPTION reaps the table
+-- re-enable table logging for verification
+ALTER SUBSCRIPTION regress_conflict_test1 SET (conflict_log_destination = 'table');
+
+-- fail - drop table not allowed due to internal dependency
+-- use DO block to hide OID in error message
+DO $$
+BEGIN
+ EXECUTE 'DROP TABLE ' || (SELECT 'pg_conflict.conflict_log_table_' || oid FROM pg_subscription WHERE subname = 'regress_conflict_test1');
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege';
+END $$;
+
+-- PUBLICATION: Verify internal tables are not publishable
+-- pg_relation_is_publishable should return false for internal conflict log tables
+SELECT pg_relation_is_publishable(subconflictlogrelid)
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- CLEANUP: Proper drop reaps the table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+
+-- Verify the table OID for reap check
+SELECT 'conflict_log_table_' || oid AS internal_tablename FROM pg_subscription WHERE subname = 'regress_conflict_test1' \gset
+
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the internal table was reaped via dependency
+SELECT to_regclass(:'internal_tablename');
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_log_default DISABLE;
+ALTER SUBSCRIPTION regress_conflict_log_default SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_log_default;
+
+ALTER SUBSCRIPTION regress_conflict_empty_str DISABLE;
+ALTER SUBSCRIPTION regress_conflict_empty_str SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_empty_str;
+
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+
RESET SESSION AUTHORIZATION;
+DROP SCHEMA clt;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
DROP ROLE regress_subscription_user3;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 04845d5e680..b51cd08f1c7 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -500,6 +500,8 @@ ConditionVariableMinimallyPadded
ConditionalStack
ConfigData
ConfigVariable
+ConflictLogColumnDef
+ConflictLogDest
ConflictTupleInfo
ConflictType
ConnCacheEntry
--
2.49.0
[application/octet-stream] v16-0002-Implement-the-conflict-insertion-infrastructure-.patch (34.7K, ../../CAFiTN-u+_mFj9caYYFO7=_YHFXk5y=vvOm2H2=5hctYktmAVGA@mail.gmail.com/3-v16-0002-Implement-the-conflict-insertion-infrastructure-.patch)
download | inline diff:
From 8ebaffe83b9a4a1bf1229a0bf46a49962fea2f90 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Sat, 20 Dec 2025 15:20:09 +0530
Subject: [PATCH v16 2/3] Implement the conflict insertion infrastructure for
the conflict log table
This patch introduces the core logic to populate the conflict log table whenever
a logical replication conflict is detected. It captures the remote transaction
details along with the corresponding local state at the time of the conflict.
Handling Multi-row Conflicts: A single remote tuple may conflict with multiple
local tuples (e.g., in the case of multiple_unique_conflicts). To handle this,
the infrastructure creates a single row in the conflict log table for each
remote tuple. The details of all conflicting local rows are aggregated into a
single JSON array in the local_conflicts column.
The JSON array uses the following structured format:
[ { "xid": "1001", "commit_ts": "2025-12-25 10:00:00+05:30", "origin": "node_1",
"key": {"id": 1}, "tuple": {"id": 1, "val": "old_data"} }, ... ]
Example of querying the structured conflict data:
SELECT remote_xid, relname, remote_origin, local_conflicts[1] ->> 'xid' AS local_xid,
local_conflicts[1] ->> 'tuple' AS local_tuple
FROM myschema.conflict_log_history2;
remote_xid | relname | remote_origin | local_xid | local_tuple
------------+----------+---------------+-----------+---------------------
760 | test | pg_16406 | 771 | {"a":1,"b":10}
765 | conf_tab | pg_16406 | 775 | {"a":2,"b":2,"c":2}
---
src/backend/replication/logical/conflict.c | 606 ++++++++++++++++++-
src/backend/replication/logical/launcher.c | 1 +
src/backend/replication/logical/worker.c | 32 +-
src/include/replication/conflict.h | 4 +-
src/include/replication/worker_internal.h | 7 +
src/test/subscription/t/037_conflict_dest.pl | 181 ++++++
6 files changed, 797 insertions(+), 34 deletions(-)
create mode 100644 src/test/subscription/t/037_conflict_dest.pl
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 16695592265..e5582d191c0 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -15,13 +15,20 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/tableam.h"
+#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "pgstat.h"
#include "replication/conflict.h"
#include "replication/worker_internal.h"
#include "storage/lmgr.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/lsyscache.h"
+#include "utils/pg_lsn.h"
+#include "utils/jsonb.h"
static const char *const ConflictTypeNames[] = {
[CT_INSERT_EXISTS] = "insert_exists",
@@ -34,6 +41,19 @@ static const char *const ConflictTypeNames[] = {
[CT_MULTIPLE_UNIQUE_CONFLICTS] = "multiple_unique_conflicts"
};
+/* Schema for the elements within the 'local_conflicts' JSON array */
+static const ConflictLogColumnDef LocalConflictSchema[] =
+{
+ { .attname = "xid", .atttypid = XIDOID },
+ { .attname = "commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "origin", .atttypid = TEXTOID },
+ { .attname = "key", .atttypid = JSONOID },
+ { .attname = "tuple", .atttypid = JSONOID }
+};
+
+#define MAX_LOCAL_CONFLICT_INFO_ATTRS \
+ (sizeof(LocalConflictSchema) / sizeof(LocalConflictSchema[0]))
+
static int errcode_apply_conflict(ConflictType type);
static void errdetail_apply_conflict(EState *estate,
ResultRelInfo *relinfo,
@@ -50,8 +70,27 @@ static char *build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *localslot,
TupleTableSlot *remoteslot,
Oid indexoid);
+static void build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull);
static char *build_index_value_desc(EState *estate, Relation localrel,
TupleTableSlot *slot, Oid indexoid);
+static Datum tuple_table_slot_to_json_datum(TupleTableSlot *slot);
+static Datum tuple_table_slot_to_indextup_json(EState *estate,
+ Relation localrel,
+ Oid replica_index,
+ TupleTableSlot *slot);
+static TupleDesc build_conflict_tupledesc(void);
+static Datum build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples);
+static void prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot);
/*
* Get the xmin and commit timestamp data (origin and timestamp) associated
@@ -105,11 +144,19 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
ConflictType type, TupleTableSlot *searchslot,
TupleTableSlot *remoteslot, List *conflicttuples)
{
- Relation localrel = relinfo->ri_RelationDesc;
- StringInfoData err_detail;
+ Relation localrel = relinfo->ri_RelationDesc;
+ StringInfoData err_detail;
+ ConflictLogDest dest;
+ Relation conflictlogrel;
initStringInfo(&err_detail);
+ /*
+ * Get both the conflict log destination and the opened conflict log
+ * relation for insertion.
+ */
+ conflictlogrel = GetConflictLogTableInfo(&dest);
+
/* Form errdetail message by combining conflicting tuples information. */
foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
errdetail_apply_conflict(estate, relinfo, type, searchslot,
@@ -120,15 +167,62 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
conflicttuple->ts,
&err_detail);
+ /* Insert to table if destination is 'table' or 'all' */
+ if (conflictlogrel)
+ {
+ Assert((dest & CONFLICT_LOG_DEST_TABLE) != 0);
+
+ if (ValidateConflictLogTable(conflictlogrel))
+ {
+ /*
+ * Prepare the conflict log tuple. If the error level is below
+ * ERROR, insert it immediately. Otherwise, defer the insertion to
+ * a new transaction after the current one aborts, ensuring the
+ * insertion of the log tuple is not rolled back.
+ */
+ prepare_conflict_log_tuple(estate,
+ relinfo->ri_RelationDesc,
+ conflictlogrel,
+ type,
+ searchslot,
+ conflicttuples,
+ remoteslot);
+ if (elevel < ERROR)
+ InsertConflictLogTuple(conflictlogrel);
+ }
+
+ table_close(conflictlogrel, RowExclusiveLock);
+ }
+
pgstat_report_subscription_conflict(MySubscription->oid, type);
- ereport(elevel,
- errcode_apply_conflict(type),
- errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
- get_namespace_name(RelationGetNamespace(localrel)),
- RelationGetRelationName(localrel),
- ConflictTypeNames[type]),
- errdetail_internal("%s", err_detail.data));
+ /* Decide what detail to show in server logs. */
+ if (dest == CONFLICT_LOG_DEST_LOG || dest == CONFLICT_LOG_DEST_ALL)
+ {
+ /* Standard reporting with full internal details. */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail_internal("%s", err_detail.data));
+ }
+ else
+ {
+ /*
+ * 'table' only: Report the error msg but omit raw tuple data from
+ * server logs since it's already captured in the internal table.
+ */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail("Conflict details logged to internal table with OID %u.",
+ MySubscription->conflictlogrelid));
+ }
}
/*
@@ -162,6 +256,143 @@ InitConflictIndexes(ResultRelInfo *relInfo)
relInfo->ri_onConflictArbiterIndexes = uniqueIndexes;
}
+/*
+ * GetConflictLogTableInfo
+ *
+ * Fetches conflict logging metadata from the cached MySubscription pointer.
+ * Sets the destination enum in *log_dest and, if applicable, opens and
+ * returns the relation handle for the internal log table.
+ */
+Relation
+GetConflictLogTableInfo(ConflictLogDest *log_dest)
+{
+ Oid conflictlogrelid;
+ Relation conflictlogrel = NULL;
+
+ /*
+ * Convert the text log destination to the internal enum. MySubscription
+ * already contains the data from pg_subscription.
+ */
+ *log_dest = GetLogDestination(MySubscription->conflictlogdest);
+ conflictlogrelid = MySubscription->conflictlogrelid;
+
+ /* If destination is 'log' only, no table to open. */
+ if (*log_dest == CONFLICT_LOG_DEST_LOG)
+ return NULL;
+
+ Assert(OidIsValid(conflictlogrelid));
+
+ conflictlogrel = table_open(conflictlogrelid, RowExclusiveLock);
+
+ /* Conflict log table is dropped or not accessible. */
+ if (conflictlogrel == NULL)
+ ereport(WARNING,
+ (errcode(ERRCODE_UNDEFINED_TABLE),
+ errmsg("conflict log table with OID %u does not exist",
+ conflictlogrelid)));
+
+ return conflictlogrel;
+}
+
+/*
+ * InsertConflictLogTuple
+ *
+ * Insert conflict log tuple into the conflict log table. It uses
+ * HEAP_INSERT_NO_LOGICAL to explicitly block logical decoding of the tuple
+ * inserted into the conflict log table.
+ */
+void
+InsertConflictLogTuple(Relation conflictlogrel)
+{
+ int options = HEAP_INSERT_NO_LOGICAL;
+
+ /* A valid tuple must be prepared and stored in MyLogicalRepWorker. */
+ Assert(MyLogicalRepWorker->conflict_log_tuple != NULL);
+
+ heap_insert(conflictlogrel, MyLogicalRepWorker->conflict_log_tuple,
+ GetCurrentCommandId(true), options, NULL);
+
+ /* Free conflict log tuple. */
+ heap_freetuple(MyLogicalRepWorker->conflict_log_tuple);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+}
+
+/*
+ * ValidateConflictLogTable - Validate conflict log table schema
+ *
+ * Checks whether the table definition including its column names, data
+ * types, and column ordering meet the requirements for conflict log
+ * table.
+ */
+bool
+ValidateConflictLogTable(Relation rel)
+{
+ Relation pg_attribute;
+ HeapTuple atup;
+ ScanKeyData scankey;
+ SysScanDesc scan;
+ int attcnt = 0;
+ bool tbl_ok = true;
+
+ pg_attribute = table_open(AttributeRelationId, AccessShareLock);
+ ScanKeyInit(&scankey,
+ Anum_pg_attribute_attrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationGetRelid(rel)));
+
+ scan = systable_beginscan(pg_attribute, AttributeRelidNumIndexId, true,
+ SnapshotSelf, 1, &scankey);
+
+ /* We only need to check up to MAX_CONFLICT_ATTR_NUM attributes */
+ while (HeapTupleIsValid(atup = systable_getnext(scan)))
+ {
+ const ConflictLogColumnDef *expected;
+ int schema_idx;
+ Form_pg_attribute attForm = (Form_pg_attribute) GETSTRUCT(atup);
+
+ /* Skip system columns and dropped columns */
+ if (attForm->attnum < 1 || attForm->attisdropped)
+ continue;
+
+ attcnt++;
+
+ /* attnum 1 corresponds to index 0 in ConflictLogSchema */
+ schema_idx = attForm->attnum - 1;
+
+ /* Check against the central schema definition */
+ if (schema_idx >= MAX_CONFLICT_ATTR_NUM)
+ {
+ /* Found an extra column beyond the required set */
+ tbl_ok = false;
+ break;
+ }
+
+ expected = &ConflictLogSchema[schema_idx];
+
+ if (attForm->atttypid != expected->atttypid ||
+ strcmp(NameStr(attForm->attname), expected->attname) != 0)
+ {
+ tbl_ok = false;
+ break;
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_attribute, AccessShareLock);
+
+ if (attcnt != MAX_CONFLICT_ATTR_NUM || !tbl_ok)
+ {
+ ereport(WARNING,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("conflict log table \"%s.%s\" structure changed, skipping insertion",
+ get_namespace_name(RelationGetNamespace(rel)),
+ RelationGetRelationName(rel)));
+ return false;
+ }
+
+ return true;
+}
+
/*
* Add SQLSTATE error code to the current conflict report.
*/
@@ -472,6 +703,40 @@ build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
return tuple_value.data;
}
+/*
+ * Helper function to extract the "raw" index key Datums and their null flags
+ * from a TupleTableSlot, given an already open index descriptor.
+ * This is the reusable core logic.
+ */
+static void
+build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull)
+{
+ TupleTableSlot *tableslot = slot;
+
+ /*
+ * If the slot is a virtual slot, copy it into a heap tuple slot as
+ * FormIndexDatum only works with heap tuple slots.
+ */
+ if (TTS_IS_VIRTUAL(slot))
+ {
+ /* Slot is created within the EState's tuple table */
+ tableslot = table_slot_create(localrel, &estate->es_tupleTable);
+ tableslot = ExecCopySlot(tableslot, slot);
+ }
+
+ /*
+ * Initialize ecxt_scantuple for potential use in FormIndexDatum
+ */
+ GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+
+ /* Form the index datums */
+ FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values,
+ isnull);
+}
+
/*
* Helper functions to construct a string describing the contents of an index
* entry. See BuildIndexValueDescription for details.
@@ -487,41 +752,318 @@ build_index_value_desc(EState *estate, Relation localrel, TupleTableSlot *slot,
Relation indexDesc;
Datum values[INDEX_MAX_KEYS];
bool isnull[INDEX_MAX_KEYS];
- TupleTableSlot *tableslot = slot;
- if (!tableslot)
+ if (!slot)
return NULL;
Assert(CheckRelationOidLockedByMe(indexoid, RowExclusiveLock, true));
indexDesc = index_open(indexoid, NoLock);
- /*
- * If the slot is a virtual slot, copy it into a heap tuple slot as
- * FormIndexDatum only works with heap tuple slots.
- */
- if (TTS_IS_VIRTUAL(slot))
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+
+ index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+
+ index_close(indexDesc, NoLock);
+
+ return index_value;
+}
+
+/*
+ * tuple_table_slot_to_json_datum
+ *
+ * Helper function to convert a TupleTableSlot to Jsonb.
+ */
+static Datum
+tuple_table_slot_to_json_datum(TupleTableSlot *slot)
+{
+ HeapTuple tuple;
+ Datum datum;
+ Datum json;
+
+ Assert(slot != NULL);
+
+ tuple = ExecCopySlotHeapTuple(slot);
+ datum = heap_copy_tuple_as_datum(tuple, slot->tts_tupleDescriptor);
+
+ json = DirectFunctionCall1(row_to_json, datum);
+ heap_freetuple(tuple);
+
+ return json;
+}
+
+/*
+ * tuple_table_slot_to_indextup_json
+ *
+ * Fetch replica identity key from the tuple table slot and convert into a
+ * jsonb datum.
+ */
+static Datum
+tuple_table_slot_to_indextup_json(EState *estate, Relation localrel,
+ Oid indexid, TupleTableSlot *slot)
+{
+ Relation indexDesc;
+ Datum values[INDEX_MAX_KEYS];
+ bool isnull[INDEX_MAX_KEYS];
+ HeapTuple tuple;
+ TupleDesc tupdesc;
+ Datum datum;
+
+ Assert(slot != NULL);
+
+ Assert(CheckRelationOidLockedByMe(indexid, RowExclusiveLock, true));
+
+ indexDesc = index_open(indexid, NoLock);
+
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+ tupdesc = RelationGetDescr(indexDesc);
+
+ /* Bless the tupdesc so it can be looked up by row_to_json. */
+ BlessTupleDesc(tupdesc);
+
+ /* Form the replica identity tuple. */
+ tuple = heap_form_tuple(tupdesc, values, isnull);
+ datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ index_close(indexDesc, NoLock);
+
+ /* Convert to a JSONB datum. */
+ return DirectFunctionCall1(row_to_json, datum);
+}
+
+static TupleDesc
+build_conflict_tupledesc(void)
+{
+ TupleDesc tupdesc;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ for (int i = 0; i < MAX_LOCAL_CONFLICT_INFO_ATTRS; i++)
+ TupleDescInitEntry(tupdesc, (AttrNumber) (i + 1),
+ LocalConflictSchema[i].attname,
+ LocalConflictSchema[i].atttypid,
+ -1, 0);
+
+ BlessTupleDesc(tupdesc);
+
+ return tupdesc;
+}
+
+/*
+ * Builds the local conflicts JSONB array column from the list of
+ * ConflictTupleInfo objects.
+ *
+ * Example output structure:
+ * [ { "xid": "1001", "commit_ts": "...", "origin": "...", "tuple": {...} }, ... ]
+ */
+static Datum
+build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples)
+{
+ ListCell *lc;
+ List *json_datums = NIL; /* List to hold the row_to_json results (type json) */
+ Datum *json_datum_array;
+ bool *json_null_array;
+ Datum json_array_datum;
+ int num_conflicts;
+ int i;
+ int16 typlen;
+ bool typbyval;
+ char typalign;
+ TupleDesc tupdesc;
+
+ /* Build local conflicts tuple descriptor. */
+ tupdesc = build_conflict_tupledesc();
+
+ /* Process local conflict tuple list and prepare an array of JSON. */
+ foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
{
- tableslot = table_slot_create(localrel, &estate->es_tupleTable);
- tableslot = ExecCopySlot(tableslot, slot);
+ Datum values[MAX_LOCAL_CONFLICT_INFO_ATTRS] = {0};
+ bool nulls[MAX_LOCAL_CONFLICT_INFO_ATTRS] = {0};
+ char *origin_name = NULL;
+ HeapTuple tuple;
+ Datum json_datum;
+ int attno;
+
+ attno = 0;
+ values[attno++] = TransactionIdGetDatum(conflicttuple->xmin);
+
+ if (conflicttuple->ts)
+ values[attno++] = TimestampTzGetDatum(conflicttuple->ts);
+ else
+ nulls[attno++] = true;
+
+ if (conflicttuple->origin != InvalidRepOriginId)
+ replorigin_by_oid(conflicttuple->origin, true, &origin_name);
+
+ /* Store empty string if origin name for the tuple is NULL. */
+ if (origin_name != NULL)
+ values[attno++] = CStringGetTextDatum(origin_name);
+ else
+ nulls[attno++] = true;
+
+ /*
+ * Add the conflicting key values in the case of a unique constraint
+ * violation.
+ */
+ if (conflict_type == CT_INSERT_EXISTS ||
+ conflict_type == CT_UPDATE_EXISTS ||
+ conflict_type == CT_MULTIPLE_UNIQUE_CONFLICTS)
+ {
+ Oid indexoid = conflicttuple->indexoid;
+
+ Assert(OidIsValid(indexoid) && conflicttuple->slot &&
+ CheckRelationOidLockedByMe(indexoid, RowExclusiveLock,
+ true));
+ values[attno++] =
+ tuple_table_slot_to_indextup_json(estate, rel,
+ indexoid,
+ conflicttuple->slot);
+ }
+ else
+ nulls[attno++] = true;
+
+ /* Convert conflicting tuple to JSON datum. */
+ if (conflicttuple->slot)
+ values[attno] = tuple_table_slot_to_json_datum(conflicttuple->slot);
+ else
+ nulls[attno] = true;
+
+ Assert(attno + 1 == MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ tuple = heap_form_tuple(tupdesc, values, nulls);
+
+ json_datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ /*
+ * Build the higher level JSON datum in format described in function
+ * header.
+ */
+ json_datum = DirectFunctionCall1(row_to_json, json_datum);
+
+ /* Done with the temporary tuple. */
+ heap_freetuple(tuple);
+
+ /* Add to the array element. */
+ json_datums = lappend(json_datums, (void *) json_datum);
}
- /*
- * Initialize ecxt_scantuple for potential use in FormIndexDatum when
- * index expressions are present.
- */
- GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+ num_conflicts = list_length(json_datums);
- /*
- * The values/nulls arrays passed to BuildIndexValueDescription should be
- * the results of FormIndexDatum, which are the "raw" input to the index
- * AM.
- */
- FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values, isnull);
+ json_datum_array = (Datum *) palloc(num_conflicts * sizeof(Datum));
+ json_null_array = (bool *) palloc0(num_conflicts * sizeof(bool));
- index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+ i = 0;
+ foreach(lc, json_datums)
+ {
+ json_datum_array[i] = (Datum) lfirst(lc);
+ i++;
+ }
- index_close(indexDesc, NoLock);
+ /* Construct the json[] array Datum. */
+ get_typlenbyvalalign(JSONOID, &typlen, &typbyval, &typalign);
+ json_array_datum = PointerGetDatum(construct_array(json_datum_array,
+ num_conflicts,
+ JSONOID,
+ typlen,
+ typbyval,
+ typalign));
+ pfree(json_datum_array);
+ pfree(json_null_array);
+
+ return json_array_datum;
+}
- return index_value;
+/*
+ * prepare_conflict_log_tuple
+ *
+ * This routine prepares a tuple detailing a conflict encountered during
+ * logical replication. The prepared tuple will be stored in
+ * MyLogicalRepWorker->conflict_log_tuple which should be inserted into the
+ * conflict log table by calling InsertConflictLogTuple.
+ */
+static void
+prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot)
+{
+ Datum values[MAX_CONFLICT_ATTR_NUM] = {0};
+ bool nulls[MAX_CONFLICT_ATTR_NUM] = {0};
+ int attno;
+ char *remote_origin = NULL;
+ MemoryContext oldctx;
+
+ Assert(MyLogicalRepWorker->conflict_log_tuple == NULL);
+
+ /* Populate the values and nulls arrays. */
+ attno = 0;
+ values[attno++] = ObjectIdGetDatum(RelationGetRelid(rel));
+
+ values[attno++] =
+ CStringGetTextDatum(get_namespace_name(RelationGetNamespace(rel)));
+
+ values[attno++] = CStringGetTextDatum(RelationGetRelationName(rel));
+
+ values[attno++] = CStringGetTextDatum(ConflictTypeNames[conflict_type]);
+
+ if (TransactionIdIsValid(remote_xid))
+ values[attno++] = TransactionIdGetDatum(remote_xid);
+ else
+ nulls[attno++] = true;
+
+ values[attno++] = LSNGetDatum(remote_final_lsn);
+
+ if (remote_commit_ts > 0)
+ values[attno++] = TimestampTzGetDatum(remote_commit_ts);
+ else
+ nulls[attno++] = true;
+
+ if (replorigin_session_origin != InvalidRepOriginId)
+ replorigin_by_oid(replorigin_session_origin, true, &remote_origin);
+
+ if (remote_origin != NULL)
+ values[attno++] = CStringGetTextDatum(remote_origin);
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(searchslot))
+ {
+ Oid replica_index = GetRelationIdentityOrPK(rel);
+
+ /*
+ * If the table has a valid replica identity index, build the index
+ * json datum from key value. Otherwise, construct it from the complete
+ * tuple in REPLICA IDENTITY FULL cases.
+ */
+ if (OidIsValid(replica_index))
+ values[attno++] = tuple_table_slot_to_indextup_json(estate, rel,
+ replica_index,
+ searchslot);
+ else
+ values[attno++] = tuple_table_slot_to_json_datum(searchslot);
+ }
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(remoteslot))
+ values[attno++] = tuple_table_slot_to_json_datum(remoteslot);
+ else
+ nulls[attno++] = true;
+
+ values[attno] = build_local_conflicts_json_array(estate, rel,
+ conflict_type,
+ conflicttuples);
+
+ Assert(attno + 1 == MAX_CONFLICT_ATTR_NUM);
+
+ oldctx = MemoryContextSwitchTo(ApplyContext);
+ MyLogicalRepWorker->conflict_log_tuple =
+ heap_form_tuple(RelationGetDescr(conflictlogrel), values, nulls);
+ MemoryContextSwitchTo(oldctx);
}
diff --git a/src/backend/replication/logical/launcher.c b/src/backend/replication/logical/launcher.c
index 3991e1495d4..bc7e1d9ebde 100644
--- a/src/backend/replication/logical/launcher.c
+++ b/src/backend/replication/logical/launcher.c
@@ -477,6 +477,7 @@ retry:
worker->oldest_nonremovable_xid = retain_dead_tuples
? MyReplicationSlot->data.xmin
: InvalidTransactionId;
+ worker->conflict_log_tuple = NULL;
worker->last_lsn = InvalidXLogRecPtr;
TIMESTAMP_NOBEGIN(worker->last_send_time);
TIMESTAMP_NOBEGIN(worker->last_recv_time);
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index fc64476a9ef..05912a6050e 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -482,7 +482,9 @@ static bool MySubscriptionValid = false;
static List *on_commit_wakeup_workers_subids = NIL;
bool in_remote_transaction = false;
-static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+TransactionId remote_xid = InvalidTransactionId;
+TimestampTz remote_commit_ts = 0;
/* fields valid only when processing streamed transaction */
static bool in_streamed_transaction = false;
@@ -1219,6 +1221,8 @@ apply_handle_begin(StringInfo s)
set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
remote_final_lsn = begin_data.final_lsn;
+ remote_commit_ts = begin_data.committime;
+ remote_xid = begin_data.xid;
maybe_start_skipping_changes(begin_data.final_lsn);
@@ -1745,6 +1749,10 @@ apply_handle_stream_start(StringInfo s)
/* extract XID of the top-level transaction */
stream_xid = logicalrep_read_stream_start(s, &first_segment);
+ remote_xid = stream_xid;
+ remote_final_lsn = InvalidXLogRecPtr;
+ remote_commit_ts = 0;
+
if (!TransactionIdIsValid(stream_xid))
ereport(ERROR,
(errcode(ERRCODE_PROTOCOL_VIOLATION),
@@ -5609,6 +5617,28 @@ start_apply(XLogRecPtr origin_startpos)
pgstat_report_subscription_error(MySubscription->oid,
MyLogicalRepWorker->type);
+ /*
+ * Insert any pending conflict log tuple under a new transaction.
+ */
+ if (MyLogicalRepWorker->conflict_log_tuple != NULL)
+ {
+ Relation conflictlogrel;
+ ConflictLogDest dest;
+
+ StartTransactionCommand();
+ PushActiveSnapshot(GetTransactionSnapshot());
+
+ /* Open conflict log table and insert the tuple. */
+ conflictlogrel = GetConflictLogTableInfo(&dest);
+ if (ValidateConflictLogTable(conflictlogrel))
+ InsertConflictLogTuple(conflictlogrel);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+ table_close(conflictlogrel, RowExclusiveLock);
+
+ PopActiveSnapshot();
+ CommitTransactionCommand();
+ }
+
PG_RE_THROW();
}
}
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index 63b68d66543..609f5b3cdcf 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -132,7 +132,6 @@ static const ConflictLogColumnDef ConflictLogSchema[] =
{ .attname = "local_conflicts", .atttypid = JSONARRAYOID }
};
-/* Define the count using the array size */
#define MAX_CONFLICT_ATTR_NUM (sizeof(ConflictLogSchema) / sizeof(ConflictLogSchema[0]))
extern bool GetTupleTransactionInfo(TupleTableSlot *localslot,
@@ -145,4 +144,7 @@ extern void ReportApplyConflict(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *remoteslot,
List *conflicttuples);
extern void InitConflictIndexes(ResultRelInfo *relInfo);
+extern Relation GetConflictLogTableInfo(ConflictLogDest *log_dest);
+extern void InsertConflictLogTuple(Relation conflictlogrel);
+extern bool ValidateConflictLogTable(Relation rel);
#endif
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index f081619f151..711c04c7297 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -101,6 +101,9 @@ typedef struct LogicalRepWorker
*/
TransactionId oldest_nonremovable_xid;
+ /* A conflict log tuple that is prepared but not yet inserted. */
+ HeapTuple conflict_log_tuple;
+
/* Stats. */
XLogRecPtr last_lsn;
TimestampTz last_send_time;
@@ -256,6 +259,10 @@ extern PGDLLIMPORT bool InitializingApplyWorker;
extern PGDLLIMPORT List *table_states_not_ready;
+extern XLogRecPtr remote_final_lsn;
+extern TimestampTz remote_commit_ts;
+extern TransactionId remote_xid;
+
extern void logicalrep_worker_attach(int slot);
extern LogicalRepWorker *logicalrep_worker_find(LogicalRepWorkerType wtype,
Oid subid, Oid relid,
diff --git a/src/test/subscription/t/037_conflict_dest.pl b/src/test/subscription/t/037_conflict_dest.pl
new file mode 100644
index 00000000000..0d9e6c5274f
--- /dev/null
+++ b/src/test/subscription/t/037_conflict_dest.pl
@@ -0,0 +1,181 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+# Test conflicts in logical replication
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+###############################
+# Setup
+###############################
+
+# Create a publisher node
+my $node_publisher = PostgreSQL::Test::Cluster->new('publisher');
+$node_publisher->init(allows_streaming => 'logical');
+$node_publisher->start;
+
+# Create a subscriber node
+my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
+$node_subscriber->init(allows_streaming => 'logical');
+$node_subscriber->start;
+
+# Create a table on publisher
+$node_publisher->safe_psql('postgres',
+ "CREATE TABLE conf_tab (a int PRIMARY KEY, b int UNIQUE, c int UNIQUE);");
+
+$node_publisher->safe_psql('postgres',
+ "CREATE TABLE conf_tab_2 (a int PRIMARY KEY, b int UNIQUE, c int UNIQUE);"
+);
+
+# Create same table on subscriber
+$node_subscriber->safe_psql('postgres',
+ "CREATE TABLE conf_tab (a int PRIMARY key, b int UNIQUE, c int UNIQUE);");
+
+$node_subscriber->safe_psql(
+ 'postgres', qq[
+ CREATE TABLE conf_tab_2 (a int PRIMARY KEY, b int, c int, unique(a,b)) PARTITION BY RANGE (a);
+ CREATE TABLE conf_tab_2_p1 PARTITION OF conf_tab_2 FOR VALUES FROM (MINVALUE) TO (100);
+]);
+
+# Setup logical replication
+my $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
+$node_publisher->safe_psql('postgres',
+ "CREATE PUBLICATION pub_tab FOR TABLE conf_tab, conf_tab_2");
+
+# Create the subscription
+my $appname = 'sub_tab';
+$node_subscriber->safe_psql(
+ 'postgres',
+ "CREATE SUBSCRIPTION sub_tab
+ CONNECTION '$publisher_connstr application_name=$appname'
+ PUBLICATION pub_tab WITH (conflict_log_destination=table)");
+
+# Wait for initial table sync to finish
+$node_subscriber->wait_for_subscription_sync($node_publisher, $appname);
+
+##################################################
+# INSERT data on Pub and Sub
+##################################################
+
+# Insert data in the publisher table
+$node_publisher->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (1,1,1);");
+
+# Insert data in the subscriber table
+$node_subscriber->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (2,2,2), (3,3,3), (4,4,4);");
+
+###############################################################################
+# Test conflict insertion into the internal conflict log table
+###############################################################################
+
+$node_subscriber->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (10, 10, 10);");
+
+# Get the internally generated table name
+my $subid = $node_subscriber->safe_psql('postgres',
+ "SELECT oid FROM pg_subscription WHERE subname = 'sub_tab';");
+my $conflict_table = "pg_conflict.conflict_log_table_$subid";
+
+$node_publisher->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (10, 20, 30);");
+
+# Wait for the conflict to be logged
+my $log_check = $node_subscriber->poll_query_until(
+ 'postgres',
+ "SELECT count(*) > 0 FROM $conflict_table;"
+);
+
+is($log_check, 1, 'Conflict was successfully logged to the internal table');
+
+my $json_query = qq[
+ SELECT string_agg((unnested.j::json)->'key'->>'a', ',')
+ FROM (
+ SELECT unnest(local_conflicts) AS j
+ FROM $conflict_table
+ ) AS unnested;
+];
+
+my $all_keys = $node_subscriber->safe_psql('postgres', $json_query);
+
+# Verify that '10' is present in the resulting string
+like($all_keys, qr/10/, 'Verified that key 10 exists in the local_conflicts log');
+
+pass('Conflict type and data successfully validated in internal table');
+
+# Final cleanup for subsequent bidirectional tests in the script
+$node_subscriber->safe_psql('postgres', "TRUNCATE conf_tab;");
+
+###############################################################################
+# Test Case: update_missing
+###############################################################################
+
+# Sync a row, then delete it locally on subscriber
+$node_publisher->safe_psql('postgres', "INSERT INTO conf_tab VALUES (50, 50, 50);");
+$node_publisher->wait_for_catchup($appname);
+$node_subscriber->safe_psql('postgres', "DELETE FROM conf_tab WHERE a = 50;");
+
+# Trigger conflict by updating that row on publisher
+$node_publisher->safe_psql('postgres', "UPDATE conf_tab SET b = 500 WHERE a = 50;");
+
+# Wait for the apply worker to detect the missing row and log it
+$node_subscriber->poll_query_until('postgres',
+ "SELECT count(*) > 0 FROM $conflict_table WHERE conflict_type = 'update_missing';"
+) or die "Timed out waiting for update_missing conflict";
+
+my $upd_miss_check = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM $conflict_table WHERE conflict_type = 'update_missing';");
+is($upd_miss_check, 1, 'Verified update_missing conflict logged to internal table');
+
+$node_subscriber->safe_psql('postgres', "TRUNCATE conf_tab;");
+
+###############################################################################
+# Test Case: insert_exists (via secondary unique index)
+###############################################################################
+
+# 1. Subscriber has a row with b=100
+$node_subscriber->safe_psql('postgres', "INSERT INTO conf_tab VALUES (100, 100, 100);");
+
+# 2. Publisher inserts a NEW PK (101) but a DUPLICATE 'b' (100)
+$node_publisher->safe_psql('postgres', "INSERT INTO conf_tab VALUES (101, 100, 101);");
+
+# 3. Verify it appears as 'insert_exists' in your log table
+$node_subscriber->poll_query_until('postgres',
+ "SELECT count(*) > 0 FROM $conflict_table WHERE conflict_type = 'insert_exists' AND local_conflicts::text LIKE '%100%';"
+) or die "Timed out waiting for secondary index insert_exists conflict";
+
+pass('Logged insert_exists triggered by secondary unique index violation');
+
+$node_subscriber->safe_psql('postgres', "TRUNCATE conf_tab;");
+
+###############################################################################
+# CASE 3: Switching Destination to 'log' (Server Log Verification)
+###############################################################################
+
+# Switch destination
+$node_subscriber->safe_psql('postgres',
+ "ALTER SUBSCRIPTION sub_tab SET (conflict_log_destination = 'all');");
+
+$node_subscriber->safe_psql('postgres', "DELETE FROM $conflict_table;");
+# Trigger a conflict for server log (insert_exists)
+$node_subscriber->safe_psql('postgres', "INSERT INTO conf_tab VALUES (600, 600, 600);");
+$node_publisher->safe_psql('postgres', "INSERT INTO conf_tab VALUES (600, 700, 700);");
+
+# Wait for table log
+$node_subscriber->poll_query_until('postgres', "SELECT count(*) > 0 FROM $conflict_table;")
+ or die "Timed out waiting for insert_exists conflict";
+
+# Check subscriber server log
+my $log_found = $node_subscriber->wait_for_log(
+ qr/conflict detected on relation "public.conf_tab": conflict=insert_exists/
+);
+ok($log_found, 'Conflict correctly directed to server stderr log');
+
+# Verify table count DID NOT increase for this conflict
+my $table_check = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM $conflict_table WHERE local_conflicts::text LIKE '%600%';");
+is($table_check, 1, 'Table log was bypassed when destination set to log');
+
+done_testing();
--
2.49.0
[application/octet-stream] v16-0003-Doccumentation-patch.patch (11.3K, ../../CAFiTN-u+_mFj9caYYFO7=_YHFXk5y=vvOm2H2=5hctYktmAVGA@mail.gmail.com/4-v16-0003-Doccumentation-patch.patch)
download | inline diff:
From 459ed105b0f306c52d0f61e2fa70e8f564bb5ef5 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Sun, 21 Dec 2025 18:51:57 +0530
Subject: [PATCH v16 3/3] Doccumentation patch
---
doc/src/sgml/logical-replication.sgml | 123 +++++++++++++++++++++-
doc/src/sgml/ref/alter_subscription.sgml | 12 ++-
doc/src/sgml/ref/create_subscription.sgml | 36 +++++++
3 files changed, 165 insertions(+), 6 deletions(-)
diff --git a/doc/src/sgml/logical-replication.sgml b/doc/src/sgml/logical-replication.sgml
index b3faaa675ef..0063f28d041 100644
--- a/doc/src/sgml/logical-replication.sgml
+++ b/doc/src/sgml/logical-replication.sgml
@@ -253,7 +253,11 @@
The subscription is added using <link linkend="sql-createsubscription"><command>CREATE SUBSCRIPTION</command></link> and
can be stopped/resumed at any time using the
<link linkend="sql-altersubscription"><command>ALTER SUBSCRIPTION</command></link> command and removed using
- <link linkend="sql-dropsubscription"><command>DROP SUBSCRIPTION</command></link>.
+ <link linkend="sql-dropsubscription"><command>DROP SUBSCRIPTION</command></link>. When the
+ <literal>conflict_log_destination</literal> parameter is set to <literal>table</literal>
+ or <literal>all</literal>, the system automatically manages a dedicated conflict
+ storage table. This table is dropped automatically when the subscription is
+ removed via <command>DROP SUBSCRIPTION</command>.
</para>
<para>
@@ -289,6 +293,16 @@
option of <command>CREATE SUBSCRIPTION</command> for details.
</para>
+ <para>
+ Conflicts that occur during replication are typically logged as plain text
+ in the server log, which can be difficult for automated monitoring and
+ analysis. The <command>CREATE SUBSCRIPTION</command> command provides the
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination">
+ <literal>conflict_log_destination</literal></link> option to record detailed
+ conflict information in a structured, queryable format, significantly
+ improving post-mortem analysis and operational visibility of the replication
+ setup by logging to a system-managed table.
+
<sect2 id="logical-replication-subscription-slot">
<title>Logical Replication Slot Management</title>
@@ -2006,9 +2020,15 @@ Publications:
</para>
<para>
- Additional logging is triggered, and the conflict statistics are collected (displayed in the
- <link linkend="monitoring-pg-stat-subscription-stats"><structname>pg_stat_subscription_stats</structname></link> view)
- in the following <firstterm>conflict</firstterm> cases:
+ Additional logging is triggered, and the conflict statistics are collected
+ (displayed in the <link linkend="monitoring-pg-stat-subscription-stats">
+ <structname>pg_stat_subscription_stats</structname></link> view)
+ in the following <firstterm>conflict</firstterm> cases. If the subscription
+ was created with the <literal>conflict_log_destination</literal> set to
+ <literal>table</literal>, detailed conflict information is also inserted
+ into an internally managed table named
+ <literal>pg_conflict.conflict_log_table_<subscription_oid></literal>,
+ providing a structured record of all conflicts.
<variablelist>
<varlistentry id="conflict-insert-exists" xreflabel="insert_exists">
<term><literal>insert_exists</literal></term>
@@ -2117,6 +2137,92 @@ Publications:
log.
</para>
+ <para>
+ When the <literal>conflict_log_destination</literal> is set to
+ <literal>table</literal>, the system automatically creates a new table with
+ a predefined schema to log conflict details. This table is created in the
+ dedicated <literal>pg_conflict</literal> namespace.
+ The schema of this table is detailed in
+ <xref linkend="logical-replication-conflict-log-schema"/>.
+ </para>
+
+ <table id="logical-replication-conflict-log-schema">
+ <title>Conflict Log History Table Schema</title>
+ <tgroup cols="3">
+ <thead>
+ <row>
+ <entry>Column</entry>
+ <entry>Type</entry>
+ <entry>Description</entry>
+ </row>
+ </thead>
+ <tbody>
+ <row>
+ <entry><literal>relid</literal></entry>
+ <entry><type>oid</type></entry>
+ <entry>The OID of the local table where the conflict occurred.</entry>
+ </row>
+ <row>
+ <entry><literal>schemaname</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The schema name of the conflicting table.</entry>
+ </row>
+ <row>
+ <entry><literal>relname</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The name of the conflicting table.</entry>
+ </row>
+ <row>
+ <entry><literal>conflict_type</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The type of conflict that occurred (e.g., <literal>insert_exists</literal>).</entry>
+ </row>
+ <row>
+ <entry><literal>remote_xid</literal></entry>
+ <entry><type>xid</type></entry>
+ <entry>The remote transaction ID that caused the conflict.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_commit_lsn</literal></entry>
+ <entry><type>pg_lsn</type></entry>
+ <entry>The final LSN of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_commit_ts</literal></entry>
+ <entry><type>timestamptz</type></entry>
+ <entry>The remote commit timestamp of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_origin</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The origin of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_tuple</literal></entry>
+ <entry><type>json</type></entry>
+ <entry>The JSON representation of the incoming remote row that caused the conflict.</entry>
+ </row>
+ <row>
+ <entry><literal>local_conflicts</literal></entry>
+ <entry><type>json[]</type></entry>
+ <entry>
+ An array of JSON objects representing the local state for each conflict attempt.
+ Each object includes the local transaction ID (<literal>xid</literal>), commit
+ timestamp (<literal>commit_ts</literal>), origin (<literal>origin</literal>),
+ conflicting key data (<literal>key</literal>), and the full local row
+ image (<literal>tuple</literal>).
+ </entry>
+ </row>
+ </tbody>
+ </tgroup>
+ </table>
+
+ <para>
+ The conflicting row data, including the original local tuple and
+ the remote tuple, is stored in <type>JSON</type> columns (<literal>local_tuple</literal>
+ and <literal>remote_tuple</literal>) for flexible querying and analysis.
+ </para>
+
<para>
The log format for logical replication conflicts is as follows:
<synopsis>
@@ -2412,6 +2518,15 @@ CONTEXT: processing remote data for replication origin "pg_16395" during "INSER
key or replica identity defined for it.
</para>
</listitem>
+
+ <listitem>
+ <para>
+ The internal table automatically created when <literal>conflict_log_destination</literal>
+ is set to <literal>table</literal> or <literal>all</literal> is excluded from
+ logical replication. It will not be published, even if a publication on the
+ subscriber is defined using <literal>FOR ALL TABLES</literal>.
+ </para>
+ </listitem>
</itemizedlist>
</sect1>
diff --git a/doc/src/sgml/ref/alter_subscription.sgml b/doc/src/sgml/ref/alter_subscription.sgml
index 27c06439f4f..2f3bb0618f5 100644
--- a/doc/src/sgml/ref/alter_subscription.sgml
+++ b/doc/src/sgml/ref/alter_subscription.sgml
@@ -280,8 +280,9 @@ ALTER SUBSCRIPTION <replaceable class="parameter">name</replaceable> RENAME TO <
<link linkend="sql-createsubscription-params-with-origin"><literal>origin</literal></link>,
<link linkend="sql-createsubscription-params-with-failover"><literal>failover</literal></link>,
<link linkend="sql-createsubscription-params-with-two-phase"><literal>two_phase</literal></link>,
- <link linkend="sql-createsubscription-params-with-retain-dead-tuples"><literal>retain_dead_tuples</literal></link>, and
- <link linkend="sql-createsubscription-params-with-max-retention-duration"><literal>max_retention_duration</literal></link>.
+ <link linkend="sql-createsubscription-params-with-retain-dead-tuples"><literal>retain_dead_tuples</literal></link>,
+ <link linkend="sql-createsubscription-params-with-max-retention-duration"><literal>max_retention_duration</literal></link> and,
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>.
Only a superuser can set <literal>password_required = false</literal>.
</para>
@@ -339,6 +340,13 @@ ALTER SUBSCRIPTION <replaceable class="parameter">name</replaceable> RENAME TO <
<quote><literal>pg_conflict_detection</literal></quote>, created to retain
dead tuples for conflict detection, will be dropped.
</para>
+
+ <para>
+ When switching <literal>conflict_log_destination</literal> to <literal>table</literal>,
+ the system will ensure the internal logging table exists. If switching away
+ from <literal>table</literal>, the logging stops, but the previously recorded
+ data remains until the subscription is dropped.
+ </para>
</listitem>
</varlistentry>
diff --git a/doc/src/sgml/ref/create_subscription.sgml b/doc/src/sgml/ref/create_subscription.sgml
index 197be0c6f6b..71119320f16 100644
--- a/doc/src/sgml/ref/create_subscription.sgml
+++ b/doc/src/sgml/ref/create_subscription.sgml
@@ -274,6 +274,42 @@ CREATE SUBSCRIPTION <replaceable class="parameter">subscription_name</replaceabl
</listitem>
</varlistentry>
+ <varlistentry id="sql-createsubscription-params-with-conflict-log-destination">
+ <term><literal>conflict_log_destination</literal> (<type>enum</type>)</term>
+ <listitem>
+ <para>
+ Specifies the destination for recording logical replication conflicts.
+ The supported values are <literal>log</literal>, <literal>table</literal>,
+ and <literal>all</literal>. The default is <literal>log</literal>.
+ </para>
+ <para>
+ The available destinations are:
+ <itemizedlist>
+ <listitem>
+ <para>
+ <literal>log</literal>: Conflict details are recorded in the server log.
+ This is the default behavior.
+ </para>
+ </listitem>
+ <listitem>
+ <para>
+ <literal>table</literal>: The system automatically creates a structured table
+ named <literal>pg_conflict.conflict_log_table_<subid></literal>.
+ This allows for easy querying and analysis of conflicts. This table is
+ automatically dropped when the subscription is removed.
+ </para>
+ </listitem>
+ <listitem>
+ <para>
+ <literal>all</literal>: Records the conflict information to both the server log
+ and the dedicated conflict table.
+ </para>
+ </listitem>
+ </itemizedlist>
+ </para>
+ </listitem>
+ </varlistentry>
+
<varlistentry id="sql-createsubscription-params-with-streaming">
<term><literal>streaming</literal> (<type>enum</type>)</term>
<listitem>
--
2.49.0
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-26 15:27 vignesh C <vignesh21@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
5 siblings, 1 reply; 912+ messages in thread
From: vignesh C @ 2025-12-26 15:27 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, 25 Dec 2025 at 13:10, Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Wed, Dec 24, 2025 at 4:02 PM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > On Tue, Dec 23, 2025 at 5:52 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > On Tue, Dec 23, 2025 at 5:18 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > > >
> > > > On Tue, Dec 23, 2025 at 10:55 AM shveta malik <shveta.malik@gmail.com> wrote:
> > > > >
> > > > > On Mon, Dec 22, 2025 at 9:11 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > > > >
> > > > > > On Mon, Dec 22, 2025 at 3:09 PM shveta malik <shveta.malik@gmail.com> wrote:
> > > > > >
> > > > > > I think this needs more thought, others can be fixed.
> > > > > >
> > > > > > > 2)
> > > > > > > postgres=# drop schema shveta cascade;
> > > > > > > NOTICE: drop cascades to subscription sub1
> > > > > > > ERROR: global objects cannot be deleted by doDeletion
> > > > > > >
> > > > > > > Is this expected? Is the user supposed to see this error?
> > > > > > >
> > > > > > See below code, so this says if the object being dropped is the
> > > > > > outermost object (i.e. if we are dropping the table directly) then it
> > > > > > will disallow dropping the object on which it has INTERNAL DEPENDENCY,
> > > > > > OTOH if the object is being dropped via recursive drop (i.e. the table
> > > > > > is being dropped while dropping the schema) then object on which it
> > > > > > has INTERNAL dependency will also be added to the deletion list and
> > > > > > later will be dropped via doDeletion and later we are getting error as
> > > > > > subscription is a global object. I thought maybe we can handle an
> > > > > > additional case that the INTERNAL DEPENDENCY, is on subscription the
> > > > > > disallow dropping it irrespective of whether it is being called
> > > > > > directly or via recursive drop but then it will give an issue even
> > > > > > when we are trying to drop table during subscription drop, we can make
> > > > > > handle this case as well via 'flags' passed in findDependentObjects()
> > > > > > but need more investigation.
> > > > > >
> > > > > > Seeing this complexity makes me think more on is it really worth it to
> > > > > > maintain this dependency? Because during subscription drop we anyway
> > > > > > have to call performDeletion externally because this dependency is
> > > > > > local so we are just disallowing the conflict table drop, however the
> > > > > > ALTER table is allowed so what we are really protecting by protecting
> > > > > > the table drop, I think it can be just documented that if user try to
> > > > > > drop the table then conflict will not be inserted anymore?
> > > > > >
> > > > > > findDependentObjects()
> > > > > > {
> > > > > > ...
> > > > > > switch (foundDep->deptype)
> > > > > > {
> > > > > > ....
> > > > > > case DEPENDENCY_INTERNAL:
> > > > > > * 1. At the outermost recursion level, we must disallow the
> > > > > > * DROP. However, if the owning object is listed in
> > > > > > * pendingObjects, just release the caller's lock and return;
> > > > > > * we'll eventually complete the DROP when we reach that entry
> > > > > > * in the pending list.
> > > > > > }
> > > > > > }
> > > > > >
> > > > > > [1]
> > > > > > postgres[1333899]=# select * from pg_depend where objid > 16410;
> > > > > > classid | objid | objsubid | refclassid | refobjid | refobjsubid | deptype
> > > > > > ---------+-------+----------+------------+----------+-------------+---------
> > > > > > 1259 | 16420 | 0 | 2615 | 16410 | 0 | n
> > > > > > 1259 | 16420 | 0 | 6100 | 16419 | 0 | i
> > > > > > (4 rows)
> > > > > >
> > > > > > 16420 -> conflict_log_table_16419
> > > > > > 16419 -> subscription
> > > > > > 16410 -> schema s1
> > > > > >
> > > > >
> > > > > One approach could be to use something similar to
> > > > > PERFORM_DELETION_SKIP_EXTENSIONS in our case, but only for recursive
> > > > > drops. The effect would be that 'DROP SCHEMA ... CASCADE' would
> > > > > proceed without error, i.e., it would drop the tables as well without
> > > > > including the subscription in the dependency list. But if we try to
> > > > > drop a table directly (e.g., DROP TABLE CLT), it will still result in:
> > > > > ERROR: cannot drop table because subscription sub1 requires it
> > > > >
> > > >
> > > > I think this way of allowing dropping the conflict table without
> > > > caring for the parent object (subscription) is not a good idea. How
> > > > about creating a dedicated schema, say pg_conflict for the purpose of
> > > > storing conflict tables? This will be similar to the pg_toast schema
> > > > for toast tables. So, similar to that each database will have a
> > > > pg_conflict schema. It prevents the "orphan" problem where a user
> > > > accidentally drops the logging schema but the Subscription is still
> > > > trying to write to it. pg_dump needs to ignore all system schemas
> > > > EXCEPT pg_conflict. This ensures the history is preserved during
> > > > migrations while still protecting the tables from accidental user
> > > > deletion. About permissions, I think we need to set the schema
> > > > permissions so that USAGE is public (so users can SELECT from their
> > > > logs) but CREATE is restricted to the superuser/subscription owner. We
> > > > may need to think some more about permissions.
> > > >
> > > > I also tried to reason out if we can allow storing the conflict table
> > > > in pg_catalog but here are a few reasons why it won't be a good idea.
> > > > I think by default, pg_dump completely ignores the pg_catalog schema.
> > > > It assumes pg_catalog contains static system definitions (like
> > > > pg_class, pg_proc, etc.) that are re-generated by the initdb process,
> > > > not user data. If we place a conflict table in pg_catalog, it will not
> > > > be backed up. If a user runs pg_dump/all to migrate to a new server,
> > > > their subscription definition will survive, but their entire history
> > > > of conflict logs will vanish. Also from the permissions angle, If a
> > > > user wants to write a custom PL/pgSQL function to "retry" conflicts,
> > > > they might need to DELETE rows from the conflict table after fixing
> > > > them. Granting DELETE permissions on a table inside pg_catalog is
> > > > non-standard and often frowned upon by security auditors. It blurs the
> > > > line between "System Internals" (immutable) and "User Data" (mutable).
> > > > So, in short a separate pg_conflict schema appears to be a better solution.
> > >
> > > Yeah that makes sense. Although I haven't thought about all cases
> > > whether it can be a problem anywhere, but meanwhile I tried
> > > prototyping with this and it behaves what we want.
> > >
> > > postgres[1651968]=# select * from pg_conflict.conflict_log_table_16406 ;
> > > relid | schemaname | relname | conflict_type | remote_xid |
> > > remote_commit_lsn | remote_commit_ts | remote_origin |
> > > replica_identity | remote_tuple
> > > |
> > > local_conflicts
> > > -------+------------+---------+-----------------------+------------+-------------------+-------------------------------+---------------+------------------+----------------
> > > +------------------------------------------------------------------------------------------------------------------------------------
> > > 16385 | public | test | update_origin_differs | 761 |
> > > 0/01760BD8 | 2025-12-23 11:08:30.583816+00 | pg_16406 |
> > > {"a":1} | {"a":1,"b":20}
> > > | {"{\"xid\":\"772\",\"commit_ts\":\"2025-12-23T11:08:25.568561+00:00\",\"origin\":null,\"key\":null,\"tuple\":{\"a\":1,\"b\":10}}"}
> > > (1 row)
> > >
> > > -- Case1: Alter is not allowed
> > > postgres[1651968]=# ALTER TABLE pg_conflict.conflict_log_table_16406
> > > ADD COLUMN a int;
> > > ERROR: 42501: permission denied: "conflict_log_table_16406" is a system catalog
> > > LOCATION: RangeVarCallbackForAlterRelation, tablecmds.c:19634
> > >
> >
> > How was this achieved? Did you modify IsSystemClass to behave
> > similarly to IsToastClass?
>
> Right
>
> > I tried to analyze whether there are alternative approaches. The
> > possible options I see are:
> >
> > 1)
> > heap_create_with_catalog() provides the boolean argument use_user_acl,
> > which is meant to apply user-defined default privileges. In theory, we
> > could predefine default ACLs for our schema and then invoke
> > heap_create_with_catalog() with use_user_acl = true. But it’s not
> > clear how to do this purely from internal code. We would need to mimic
> > or reuse the logic behind SetDefaultACLsInSchemas.
> > 2)
> > Another option is to create the table using heap_create_with_catalog()
> > with use_user_acl = false, and then explicitly update pg_class.relacl
> > for that table, similar to what ExecGrant_Relation does when
> > processing GRANT/REVOKE. But I couldn’t find any existing internal
> > code paths (outside of the GRANT/REVOKE implementation itself) that do
> > this kind of post-creation ACL manipulation.
>
> I haven't analyzed this options, I will do that but not before Jan 3rd
> as I will be away from my laptop for a week.
>
> > So overall, I feel changing IsSystemClass is the simpler way right
> > now. To set ACL before/after/during heap_create_with_catalog is a
> > tricky thing, at-least I could not find an easier way to do this,
> > unless I have missed something.
> > Thoughts on possible approaches?
>
> Here is the patches I have changed by using IsSystemClass(), based on
> this many other things changed like we don't need to check for the
> temp schema and also the caller of create_conflict_log_table() now
> don't need to find the creation schema so it don't need to generate
> the relname so that part is also moved within
> create_conflict_log_table(). Fixed most of the comments given by
> Peter and Shveta, although some of them are still open e.g. the name
> of the conflict log table as of now I have kept as
> conflict_log_table_<subid> other options are
>
> 1. pg_conflict_<subid>
> 2. conflict_log_<subid>
> 3. sub_conflict_log_<subid>
>
> I prefer 3, considering it says this table holds subscription conflict
> logs. Thoughts?
>
> Vignesh, your patches have to be rebased on the new version.
Here is a rebased version of the remaining patches.
Regards,
Vignesh
Attachments:
[text/x-patch] v17-0003-Doccumentation-patch.patch (11.3K, ../../CALDaNm1aivk9KgQ5daeF6YZzuE+0wWc2yb7wb6qikNyvfPN0Sg@mail.gmail.com/2-v17-0003-Doccumentation-patch.patch)
download | inline diff:
From f24520a69c98375bb93f3b900a6fa41e8e9660e8 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Sun, 21 Dec 2025 18:51:57 +0530
Subject: [PATCH v17 3/6] Doccumentation patch
---
doc/src/sgml/logical-replication.sgml | 123 +++++++++++++++++++++-
doc/src/sgml/ref/alter_subscription.sgml | 12 ++-
doc/src/sgml/ref/create_subscription.sgml | 36 +++++++
3 files changed, 165 insertions(+), 6 deletions(-)
diff --git a/doc/src/sgml/logical-replication.sgml b/doc/src/sgml/logical-replication.sgml
index 58ce75d8b63..2d57d7f6541 100644
--- a/doc/src/sgml/logical-replication.sgml
+++ b/doc/src/sgml/logical-replication.sgml
@@ -253,7 +253,11 @@
The subscription is added using <link linkend="sql-createsubscription"><command>CREATE SUBSCRIPTION</command></link> and
can be stopped/resumed at any time using the
<link linkend="sql-altersubscription"><command>ALTER SUBSCRIPTION</command></link> command and removed using
- <link linkend="sql-dropsubscription"><command>DROP SUBSCRIPTION</command></link>.
+ <link linkend="sql-dropsubscription"><command>DROP SUBSCRIPTION</command></link>. When the
+ <literal>conflict_log_destination</literal> parameter is set to <literal>table</literal>
+ or <literal>all</literal>, the system automatically manages a dedicated conflict
+ storage table. This table is dropped automatically when the subscription is
+ removed via <command>DROP SUBSCRIPTION</command>.
</para>
<para>
@@ -289,6 +293,16 @@
option of <command>CREATE SUBSCRIPTION</command> for details.
</para>
+ <para>
+ Conflicts that occur during replication are typically logged as plain text
+ in the server log, which can be difficult for automated monitoring and
+ analysis. The <command>CREATE SUBSCRIPTION</command> command provides the
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination">
+ <literal>conflict_log_destination</literal></link> option to record detailed
+ conflict information in a structured, queryable format, significantly
+ improving post-mortem analysis and operational visibility of the replication
+ setup by logging to a system-managed table.
+
<sect2 id="logical-replication-subscription-slot">
<title>Logical Replication Slot Management</title>
@@ -2006,9 +2020,15 @@ Publications:
</para>
<para>
- Additional logging is triggered, and the conflict statistics are collected (displayed in the
- <link linkend="monitoring-pg-stat-subscription-stats"><structname>pg_stat_subscription_stats</structname></link> view)
- in the following <firstterm>conflict</firstterm> cases:
+ Additional logging is triggered, and the conflict statistics are collected
+ (displayed in the <link linkend="monitoring-pg-stat-subscription-stats">
+ <structname>pg_stat_subscription_stats</structname></link> view)
+ in the following <firstterm>conflict</firstterm> cases. If the subscription
+ was created with the <literal>conflict_log_destination</literal> set to
+ <literal>table</literal>, detailed conflict information is also inserted
+ into an internally managed table named
+ <literal>pg_conflict.conflict_log_table_<subscription_oid></literal>,
+ providing a structured record of all conflicts.
<variablelist>
<varlistentry id="conflict-insert-exists" xreflabel="insert_exists">
<term><literal>insert_exists</literal></term>
@@ -2117,6 +2137,92 @@ Publications:
log.
</para>
+ <para>
+ When the <literal>conflict_log_destination</literal> is set to
+ <literal>table</literal>, the system automatically creates a new table with
+ a predefined schema to log conflict details. This table is created in the
+ dedicated <literal>pg_conflict</literal> namespace.
+ The schema of this table is detailed in
+ <xref linkend="logical-replication-conflict-log-schema"/>.
+ </para>
+
+ <table id="logical-replication-conflict-log-schema">
+ <title>Conflict Log History Table Schema</title>
+ <tgroup cols="3">
+ <thead>
+ <row>
+ <entry>Column</entry>
+ <entry>Type</entry>
+ <entry>Description</entry>
+ </row>
+ </thead>
+ <tbody>
+ <row>
+ <entry><literal>relid</literal></entry>
+ <entry><type>oid</type></entry>
+ <entry>The OID of the local table where the conflict occurred.</entry>
+ </row>
+ <row>
+ <entry><literal>schemaname</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The schema name of the conflicting table.</entry>
+ </row>
+ <row>
+ <entry><literal>relname</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The name of the conflicting table.</entry>
+ </row>
+ <row>
+ <entry><literal>conflict_type</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The type of conflict that occurred (e.g., <literal>insert_exists</literal>).</entry>
+ </row>
+ <row>
+ <entry><literal>remote_xid</literal></entry>
+ <entry><type>xid</type></entry>
+ <entry>The remote transaction ID that caused the conflict.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_commit_lsn</literal></entry>
+ <entry><type>pg_lsn</type></entry>
+ <entry>The final LSN of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_commit_ts</literal></entry>
+ <entry><type>timestamptz</type></entry>
+ <entry>The remote commit timestamp of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_origin</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The origin of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_tuple</literal></entry>
+ <entry><type>json</type></entry>
+ <entry>The JSON representation of the incoming remote row that caused the conflict.</entry>
+ </row>
+ <row>
+ <entry><literal>local_conflicts</literal></entry>
+ <entry><type>json[]</type></entry>
+ <entry>
+ An array of JSON objects representing the local state for each conflict attempt.
+ Each object includes the local transaction ID (<literal>xid</literal>), commit
+ timestamp (<literal>commit_ts</literal>), origin (<literal>origin</literal>),
+ conflicting key data (<literal>key</literal>), and the full local row
+ image (<literal>tuple</literal>).
+ </entry>
+ </row>
+ </tbody>
+ </tgroup>
+ </table>
+
+ <para>
+ The conflicting row data, including the original local tuple and
+ the remote tuple, is stored in <type>JSON</type> columns (<literal>local_tuple</literal>
+ and <literal>remote_tuple</literal>) for flexible querying and analysis.
+ </para>
+
<para>
The log format for logical replication conflicts is as follows:
<synopsis>
@@ -2412,6 +2518,15 @@ CONTEXT: processing remote data for replication origin "pg_16395" during "INSER
key or replica identity defined for it.
</para>
</listitem>
+
+ <listitem>
+ <para>
+ The internal table automatically created when <literal>conflict_log_destination</literal>
+ is set to <literal>table</literal> or <literal>all</literal> is excluded from
+ logical replication. It will not be published, even if a publication on the
+ subscriber is defined using <literal>FOR ALL TABLES</literal>.
+ </para>
+ </listitem>
</itemizedlist>
</sect1>
diff --git a/doc/src/sgml/ref/alter_subscription.sgml b/doc/src/sgml/ref/alter_subscription.sgml
index 27c06439f4f..2f3bb0618f5 100644
--- a/doc/src/sgml/ref/alter_subscription.sgml
+++ b/doc/src/sgml/ref/alter_subscription.sgml
@@ -280,8 +280,9 @@ ALTER SUBSCRIPTION <replaceable class="parameter">name</replaceable> RENAME TO <
<link linkend="sql-createsubscription-params-with-origin"><literal>origin</literal></link>,
<link linkend="sql-createsubscription-params-with-failover"><literal>failover</literal></link>,
<link linkend="sql-createsubscription-params-with-two-phase"><literal>two_phase</literal></link>,
- <link linkend="sql-createsubscription-params-with-retain-dead-tuples"><literal>retain_dead_tuples</literal></link>, and
- <link linkend="sql-createsubscription-params-with-max-retention-duration"><literal>max_retention_duration</literal></link>.
+ <link linkend="sql-createsubscription-params-with-retain-dead-tuples"><literal>retain_dead_tuples</literal></link>,
+ <link linkend="sql-createsubscription-params-with-max-retention-duration"><literal>max_retention_duration</literal></link> and,
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>.
Only a superuser can set <literal>password_required = false</literal>.
</para>
@@ -339,6 +340,13 @@ ALTER SUBSCRIPTION <replaceable class="parameter">name</replaceable> RENAME TO <
<quote><literal>pg_conflict_detection</literal></quote>, created to retain
dead tuples for conflict detection, will be dropped.
</para>
+
+ <para>
+ When switching <literal>conflict_log_destination</literal> to <literal>table</literal>,
+ the system will ensure the internal logging table exists. If switching away
+ from <literal>table</literal>, the logging stops, but the previously recorded
+ data remains until the subscription is dropped.
+ </para>
</listitem>
</varlistentry>
diff --git a/doc/src/sgml/ref/create_subscription.sgml b/doc/src/sgml/ref/create_subscription.sgml
index 197be0c6f6b..71119320f16 100644
--- a/doc/src/sgml/ref/create_subscription.sgml
+++ b/doc/src/sgml/ref/create_subscription.sgml
@@ -274,6 +274,42 @@ CREATE SUBSCRIPTION <replaceable class="parameter">subscription_name</replaceabl
</listitem>
</varlistentry>
+ <varlistentry id="sql-createsubscription-params-with-conflict-log-destination">
+ <term><literal>conflict_log_destination</literal> (<type>enum</type>)</term>
+ <listitem>
+ <para>
+ Specifies the destination for recording logical replication conflicts.
+ The supported values are <literal>log</literal>, <literal>table</literal>,
+ and <literal>all</literal>. The default is <literal>log</literal>.
+ </para>
+ <para>
+ The available destinations are:
+ <itemizedlist>
+ <listitem>
+ <para>
+ <literal>log</literal>: Conflict details are recorded in the server log.
+ This is the default behavior.
+ </para>
+ </listitem>
+ <listitem>
+ <para>
+ <literal>table</literal>: The system automatically creates a structured table
+ named <literal>pg_conflict.conflict_log_table_<subid></literal>.
+ This allows for easy querying and analysis of conflicts. This table is
+ automatically dropped when the subscription is removed.
+ </para>
+ </listitem>
+ <listitem>
+ <para>
+ <literal>all</literal>: Records the conflict information to both the server log
+ and the dedicated conflict table.
+ </para>
+ </listitem>
+ </itemizedlist>
+ </para>
+ </listitem>
+ </varlistentry>
+
<varlistentry id="sql-createsubscription-params-with-streaming">
<term><literal>streaming</literal> (<type>enum</type>)</term>
<listitem>
--
2.43.0
[text/x-patch] v17-0001-Add-configurable-conflict-log-table-for-Logical-.patch (99.2K, ../../CALDaNm1aivk9KgQ5daeF6YZzuE+0wWc2yb7wb6qikNyvfPN0Sg@mail.gmail.com/3-v17-0001-Add-configurable-conflict-log-table-for-Logical-.patch)
download | inline diff:
From 648a9e668a4832267f31384adf3911acea8f83c7 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 17 Dec 2025 11:53:47 +0530
Subject: [PATCH v17 1/6] Add configurable conflict log table for Logical
Replication
This patch adds a feature to provide a structured, queryable record of all
logical replication conflicts. The current approach of logging conflicts as
plain text in the server logs makes it difficult to query, analyze, and
use for external monitoring and automation.
This patch addresses these limitations by introducing a configurable
conflict_log_destination=('log'/'table'/'all') option in the CREATE SUBSCRIPTION
command.
If the user chooses to enable logging to a table (by selecting 'table' or 'all'),
an internal logging table named conflict_log_table_<subid> is automatically
created within a dedicated, system-managed namespace named pg_conflict. This table
is linked to the subscription via an internal dependency, ensuring it is
automatically dropped when the subscription is removed.
The conflict details, including the original and remote tuples, are stored in JSON
columns, providing a flexible format to accommodate different table schemas.
The log table captures essential attributes such as local and remote transaction IDs,
LSNs, commit timestamps, and conflict type, providing a complete record for post-mortem
analysis.
This feature will make logical replication conflicts easier to monitor and manage,
significantly improving the overall resilience and operability of replication setups.
The conflict log tables will not be included in a publication, even if the publication
is configured to include ALL TABLES or ALL TABLES IN SCHEMA.
---
src/backend/catalog/catalog.c | 15 +-
src/backend/catalog/pg_publication.c | 26 +-
src/backend/catalog/pg_subscription.c | 7 +
src/backend/commands/subscriptioncmds.c | 288 +++++++++++++++++-
src/bin/psql/describe.c | 21 +-
src/bin/psql/tab-complete.in.c | 8 +-
src/include/catalog/catalog.h | 1 +
src/include/catalog/pg_namespace.dat | 3 +
src/include/catalog/pg_subscription.h | 9 +
src/include/commands/subscriptioncmds.h | 4 +
src/include/replication/conflict.h | 56 ++++
src/test/regress/expected/subscription.out | 336 +++++++++++++++------
src/test/regress/sql/subscription.sql | 118 ++++++++
src/tools/pgindent/typedefs.list | 2 +
14 files changed, 789 insertions(+), 105 deletions(-)
diff --git a/src/backend/catalog/catalog.c b/src/backend/catalog/catalog.c
index 59caae8f1bc..e5f57d4aa69 100644
--- a/src/backend/catalog/catalog.c
+++ b/src/backend/catalog/catalog.c
@@ -86,7 +86,8 @@ bool
IsSystemClass(Oid relid, Form_pg_class reltuple)
{
/* IsCatalogRelationOid is a bit faster, so test that first */
- return (IsCatalogRelationOid(relid) || IsToastClass(reltuple));
+ return (IsCatalogRelationOid(relid) || IsToastClass(reltuple)
+ || IsConflictClass(reltuple));
}
/*
@@ -230,6 +231,18 @@ IsToastClass(Form_pg_class reltuple)
return IsToastNamespace(relnamespace);
}
+/*
+ * IsConflictClass - Check if the given pg_class tuple belongs to the conflict
+ * namespace.
+ */
+bool
+IsConflictClass(Form_pg_class reltuple)
+{
+ Oid relnamespace = reltuple->relnamespace;
+
+ return (relnamespace == PG_CONFLICT_NAMESPACE);
+}
+
/*
* IsCatalogNamespace
* True iff namespace is pg_catalog.
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index 7aa3f179924..afa2b85875e 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -31,6 +31,7 @@
#include "catalog/pg_publication_rel.h"
#include "catalog/pg_type.h"
#include "commands/publicationcmds.h"
+#include "commands/subscriptioncmds.h"
#include "funcapi.h"
#include "utils/array.h"
#include "utils/builtins.h"
@@ -85,6 +86,15 @@ check_publication_add_relation(Relation targetrel)
errmsg("cannot add relation \"%s\" to publication",
RelationGetRelationName(targetrel)),
errdetail("This operation is not supported for unlogged tables.")));
+
+ /* Can't be conflict log table */
+ if (IsConflictLogTable(RelationGetRelid(targetrel)))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot add relation \"%s.%s\" to publication",
+ get_namespace_name(RelationGetNamespace(targetrel)),
+ RelationGetRelationName(targetrel)),
+ errdetail("This operation is not supported for conflict log tables.")));
}
/*
@@ -145,6 +155,13 @@ is_publishable_class(Oid relid, Form_pg_class reltuple)
/*
* Another variant of is_publishable_class(), taking a Relation.
+ *
+ * Note: Conflict log tables are not publishable. However, we intentionally
+ * skip this check here because this function is called for every change and
+ * performing this check during every change publication is costly. To ensure
+ * unpublishable entries are ignored without incurring performance overhead,
+ * tuples inserted into the conflict log table uses the HEAP_INSERT_NO_LOGICAL
+ * flag. This allows the decoding layer to bypass these entries automatically.
*/
bool
is_publishable_relation(Relation rel)
@@ -169,7 +186,10 @@ pg_relation_is_publishable(PG_FUNCTION_ARGS)
tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
if (!HeapTupleIsValid(tuple))
PG_RETURN_NULL();
- result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple));
+
+ /* Subscription conflict log tables are not published */
+ result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple)) &&
+ !IsConflictLogTable(relid);
ReleaseSysCache(tuple);
PG_RETURN_BOOL(result);
}
@@ -890,7 +910,9 @@ GetAllPublicationRelations(char relkind, bool pubviaroot)
Form_pg_class relForm = (Form_pg_class) GETSTRUCT(tuple);
Oid relid = relForm->oid;
+ /* Subscription conflict log tables are not published */
if (is_publishable_class(relid, relForm) &&
+ !IsConflictLogTable(relid) &&
!(relForm->relispartition && pubviaroot))
result = lappend_oid(result, relid);
}
@@ -1018,7 +1040,7 @@ GetSchemaPublicationRelations(Oid schemaid, PublicationPartOpt pub_partopt)
Oid relid = relForm->oid;
char relkind;
- if (!is_publishable_class(relid, relForm))
+ if (!is_publishable_class(relid, relForm) || IsConflictLogTable(relid))
continue;
relkind = get_rel_relkind(relid);
diff --git a/src/backend/catalog/pg_subscription.c b/src/backend/catalog/pg_subscription.c
index ad6fbd77ffd..842cc417e68 100644
--- a/src/backend/catalog/pg_subscription.c
+++ b/src/backend/catalog/pg_subscription.c
@@ -106,6 +106,7 @@ GetSubscription(Oid subid, bool missing_ok)
sub->retaindeadtuples = subform->subretaindeadtuples;
sub->maxretention = subform->submaxretention;
sub->retentionactive = subform->subretentionactive;
+ sub->conflictlogrelid = subform->subconflictlogrelid;
/* Get conninfo */
datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
@@ -141,6 +142,12 @@ GetSubscription(Oid subid, bool missing_ok)
Anum_pg_subscription_suborigin);
sub->origin = TextDatumGetCString(datum);
+ /* Get conflict log destination */
+ datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
+ tup,
+ Anum_pg_subscription_subconflictlogdest);
+ sub->conflictlogdest = TextDatumGetCString(datum);
+
/* Is the subscription owner a superuser? */
sub->ownersuperuser = superuser_arg(sub->owner);
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index 4efd4685abc..c54bcb97407 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -15,25 +15,31 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/htup_details.h"
#include "access/table.h"
#include "access/twophase.h"
#include "access/xact.h"
#include "catalog/catalog.h"
#include "catalog/dependency.h"
+#include "catalog/heap.h"
#include "catalog/indexing.h"
#include "catalog/namespace.h"
#include "catalog/objectaccess.h"
#include "catalog/objectaddress.h"
+#include "catalog/pg_am_d.h"
#include "catalog/pg_authid_d.h"
#include "catalog/pg_database_d.h"
+#include "catalog/pg_namespace.h"
#include "catalog/pg_subscription.h"
#include "catalog/pg_subscription_rel.h"
#include "catalog/pg_type.h"
+#include "commands/comment.h"
#include "commands/defrem.h"
#include "commands/event_trigger.h"
#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "miscadmin.h"
#include "nodes/makefuncs.h"
#include "pgstat.h"
@@ -51,6 +57,7 @@
#include "utils/lsyscache.h"
#include "utils/memutils.h"
#include "utils/pg_lsn.h"
+#include "utils/regproc.h"
#include "utils/syscache.h"
/*
@@ -75,6 +82,7 @@
#define SUBOPT_MAX_RETENTION_DURATION 0x00008000
#define SUBOPT_LSN 0x00010000
#define SUBOPT_ORIGIN 0x00020000
+#define SUBOPT_CONFLICT_LOG_DEST 0x00040000
/* check if the 'val' has 'bits' set */
#define IsSet(val, bits) (((val) & (bits)) == (bits))
@@ -103,6 +111,7 @@ typedef struct SubOpts
bool retaindeadtuples;
int32 maxretention;
char *origin;
+ ConflictLogDest logdest;
XLogRecPtr lsn;
} SubOpts;
@@ -135,7 +144,7 @@ static List *merge_publications(List *oldpublist, List *newpublist, bool addpub,
static void ReportSlotConnectionError(List *rstates, Oid subid, char *slotname, char *err);
static void CheckAlterSubOption(Subscription *sub, const char *option,
bool slot_needs_update, bool isTopLevel);
-
+static Oid create_conflict_log_table(Oid subid, char *subname);
/*
* Common option parsing function for CREATE and ALTER SUBSCRIPTION commands.
@@ -191,6 +200,8 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->maxretention = 0;
if (IsSet(supported_opts, SUBOPT_ORIGIN))
opts->origin = pstrdup(LOGICALREP_ORIGIN_ANY);
+ if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DEST))
+ opts->logdest = CONFLICT_LOG_DEST_LOG;
/* Parse options */
foreach(lc, stmt_options)
@@ -402,6 +413,20 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->specified_opts |= SUBOPT_LSN;
opts->lsn = lsn;
}
+ else if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DEST) &&
+ strcmp(defel->defname, "conflict_log_destination") == 0)
+ {
+ char *val;
+ ConflictLogDest dest;
+
+ if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_LOG_DEST))
+ errorConflictingDefElem(defel, pstate);
+
+ val = defGetString(defel);
+ dest = GetLogDestination(val);
+ opts->logdest = dest;
+ opts->specified_opts |= SUBOPT_CONFLICT_LOG_DEST;
+ }
else
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
@@ -612,7 +637,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
SUBOPT_DISABLE_ON_ERR | SUBOPT_PASSWORD_REQUIRED |
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
- SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN);
+ SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_DEST);
parse_subscription_options(pstate, stmt->options, supported_opts, &opts);
/*
@@ -747,6 +773,31 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
values[Anum_pg_subscription_suborigin - 1] =
CStringGetTextDatum(opts.origin);
+ /* Always set the destination, default will be 'log'. */
+ values[Anum_pg_subscription_subconflictlogdest - 1] =
+ CStringGetTextDatum(ConflictLogDestNames[opts.logdest]);
+
+ /*
+ * If logging to a table is required, physically create the logging
+ * relation and store its OID in the catalog.
+ */
+ if (opts.logdest == CONFLICT_LOG_DEST_TABLE ||
+ opts.logdest == CONFLICT_LOG_DEST_ALL)
+ {
+ Oid logrelid;
+
+ /* Store the Oid returned from creation. */
+ logrelid = create_conflict_log_table(subid, stmt->subname);
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(logrelid);
+ }
+ else
+ {
+ /* Destination is 'log'; no table is needed. */
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(InvalidOid);
+ }
+
tup = heap_form_tuple(RelationGetDescr(rel), values, nulls);
/* Insert tuple into catalog. */
@@ -1410,7 +1461,8 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
SUBOPT_MAX_RETENTION_DURATION |
- SUBOPT_ORIGIN);
+ SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_DEST);
parse_subscription_options(pstate, stmt->options,
supported_opts, &opts);
@@ -1665,6 +1717,63 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
origin = opts.origin;
}
+ if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_LOG_DEST))
+ {
+ ConflictLogDest old_dest =
+ GetLogDestination(sub->conflictlogdest);
+
+ if (opts.logdest != old_dest)
+ {
+ bool want_table =
+ IsSet(opts.logdest, CONFLICT_LOG_DEST_TABLE);
+ bool has_oldtable =
+ IsSet(old_dest, CONFLICT_LOG_DEST_TABLE);
+
+ values[Anum_pg_subscription_subconflictlogdest - 1] =
+ CStringGetTextDatum(ConflictLogDestNames[opts.logdest]);
+ replaces[Anum_pg_subscription_subconflictlogdest - 1] = true;
+
+ if (want_table && !has_oldtable)
+ {
+ Oid relid;
+
+ relid = create_conflict_log_table(subid, sub->name);
+
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(relid);
+ replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
+ true;
+ }
+ else if (!want_table && has_oldtable)
+ {
+ ObjectAddress object;
+
+ /*
+ * Conflict log tables are recorded as internal
+ * dependencies of the subscription. Drop the
+ * table if it is not required anymore to avoid
+ * stale or orphaned relations.
+ *
+ * XXX: At present, only conflict log tables are
+ * managed this way. In future if we introduce
+ * additional internal dependencies, we may need a
+ * targeted deletion to avoid deletion of any
+ * other objects.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId,
+ subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(InvalidOid);
+ replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
+ true;
+ }
+ }
+ }
+
update_tuple = true;
break;
}
@@ -2027,6 +2136,7 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
Form_pg_subscription form;
List *rstates;
bool must_use_password;
+ ObjectAddress object;
/*
* The launcher may concurrently start a new worker for this subscription.
@@ -2184,6 +2294,19 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
/* Clean up dependencies */
deleteSharedDependencyRecordsFor(SubscriptionRelationId, subid, 0);
+ /*
+ * Conflict log tables are recorded as internal dependencies of the
+ * subscription. We must drop the dependent objects before the
+ * subscription itself is removed. By using
+ * PERFORM_DELETION_SKIP_ORIGINAL, we ensure that only the conflict log
+ * table is reaped while the subscription remains for the final deletion
+ * step.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId, subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
/* Remove any associated relation synchronization states. */
RemoveSubscriptionRel(subid, InvalidOid);
@@ -3190,3 +3313,162 @@ defGetStreamingMode(DefElem *def)
def->defname)));
return LOGICALREP_STREAM_OFF; /* keep compiler quiet */
}
+
+/*
+ * Builds the TupleDesc for the conflict log table.
+ */
+static TupleDesc
+create_conflict_log_table_tupdesc(void)
+{
+ TupleDesc tupdesc;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_CONFLICT_ATTR_NUM);
+
+ for (int i = 0; i < MAX_CONFLICT_ATTR_NUM; i++)
+ {
+ Oid type_oid = ConflictLogSchema[i].atttypid;
+
+ /*
+ * Special handling for the JSON array type for proper
+ * TupleDescInitEntry call.
+ */
+ if (type_oid == JSONARRAYOID)
+ type_oid = get_array_type(JSONOID);
+
+ TupleDescInitEntry(tupdesc, i + 1,
+ ConflictLogSchema[i].attname,
+ type_oid,
+ -1, 0);
+ }
+
+ return BlessTupleDesc(tupdesc);
+}
+
+/*
+ * Create a structured conflict log table for a subscription.
+ *
+ * The table is created within the dedicated 'pg_conflict' namespace, which
+ * is system-managed. The table name is generated automatically using the
+ * subscription's OID (e.g., "conflict_log_table_<subid>") to ensure uniqueness
+ * within the cluster and to avoid collisions during subscription renames.
+ */
+static Oid
+create_conflict_log_table(Oid subid, char *subname)
+{
+ TupleDesc tupdesc;
+ Oid relid;
+ ObjectAddress myself;
+ ObjectAddress subaddr;
+ char relname[NAMEDATALEN];
+
+ snprintf(relname, NAMEDATALEN, "conflict_log_table_%u", subid);
+
+ /* Report an error if the specified conflict log table already exists. */
+ if (OidIsValid(get_relname_relid(relname, PG_CONFLICT_NAMESPACE)))
+ ereport(ERROR,
+ (errcode(ERRCODE_DUPLICATE_TABLE),
+ errmsg("relation \"%s.%s\" already exists",
+ get_namespace_name(PG_CONFLICT_NAMESPACE), relname)));
+
+ /* Build the tuple descriptor for the new table. */
+ tupdesc = create_conflict_log_table_tupdesc();
+
+ /* Create conflict log table. */
+ relid = heap_create_with_catalog(relname,
+ PG_CONFLICT_NAMESPACE,
+ 0,
+ InvalidOid,
+ InvalidOid,
+ InvalidOid,
+ GetUserId(),
+ HEAP_TABLE_AM_OID,
+ tupdesc,
+ NIL,
+ RELKIND_RELATION,
+ RELPERSISTENCE_PERMANENT,
+ false,
+ false,
+ ONCOMMIT_NOOP,
+ (Datum) 0,
+ false,
+ false,
+ false,
+ InvalidOid,
+ NULL);
+
+ /*
+ * Establish an internal dependency between the conflict log table and the
+ * subscription. By using DEPENDENCY_INTERNAL, we ensure the table is
+ * automatically reaped when the subscription is dropped. This also
+ * prevents the table from being dropped independently unless the
+ * subscription itself is removed.
+ */
+ ObjectAddressSet(myself, RelationRelationId, relid);
+ ObjectAddressSet(subaddr, SubscriptionRelationId, subid);
+ recordDependencyOn(&myself, &subaddr, DEPENDENCY_INTERNAL);
+
+ /* Release tuple descriptor memory. */
+ FreeTupleDesc(tupdesc);
+
+ return relid;
+}
+
+/*
+ * GetLogDestination
+ *
+ * Convert string to enum by comparing against standardized labels.
+ */
+ConflictLogDest
+GetLogDestination(const char *dest)
+{
+ /* Empty string or NULL defaults to LOG. */
+ if (dest == NULL || dest[0] == '\0' || pg_strcasecmp(dest, "log") == 0)
+ return CONFLICT_LOG_DEST_LOG;
+
+ if (pg_strcasecmp(dest,
+ ConflictLogDestNames[CONFLICT_LOG_DEST_TABLE]) == 0)
+ return CONFLICT_LOG_DEST_TABLE;
+
+ if (pg_strcasecmp(dest, ConflictLogDestNames[CONFLICT_LOG_DEST_ALL]) == 0)
+ return CONFLICT_LOG_DEST_ALL;
+
+ /* Unrecognized string. */
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("unrecognized conflict_log_destination value: \"%s\"", dest),
+ errhint("Valid values are \"log\", \"table\", and \"all\".")));
+}
+
+/*
+ * Check if the specified relation is used as a conflict log table by any
+ * subscription.
+ */
+bool
+IsConflictLogTable(Oid relid)
+{
+ Relation rel;
+ TableScanDesc scan;
+ HeapTuple tup;
+ bool is_clt = false;
+
+ rel = table_open(SubscriptionRelationId, AccessShareLock);
+ scan = table_beginscan_catalog(rel, 0, NULL);
+
+ while (HeapTupleIsValid(tup = heap_getnext(scan, ForwardScanDirection)))
+ {
+ Form_pg_subscription subform = (Form_pg_subscription) GETSTRUCT(tup);
+
+ /* Direct Oid comparison from catalog */
+ if (OidIsValid(subform->subconflictlogrelid) &&
+ subform->subconflictlogrelid == relid)
+ {
+ is_clt = true;
+ break;
+ }
+ }
+
+ table_endscan(scan);
+ table_close(rel, AccessShareLock);
+
+ return is_clt;
+}
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index 36f24502842..d21e3d95506 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -6806,7 +6806,7 @@ describeSubscriptions(const char *pattern, bool verbose)
printQueryOpt myopt = pset.popt;
static const bool translate_columns[] = {false, false, false, false,
false, false, false, false, false, false, false, false, false, false,
- false, false, false, false};
+ false, false, false, false, false};
if (pset.sversion < 100000)
{
@@ -6900,15 +6900,22 @@ describeSubscriptions(const char *pattern, bool verbose)
appendPQExpBuffer(&buf,
", subskiplsn AS \"%s\"\n",
gettext_noop("Skip LSN"));
+
+ /* Conflict log destination is supported in v19 and higher */
+ if (pset.sversion >= 190000)
+ {
+ appendPQExpBuffer(&buf,
+ ", subconflictlogdest AS \"%s\"\n",
+ gettext_noop("Conflict log destination"));
+ }
}
/* Only display subscriptions in current database. */
- appendPQExpBufferStr(&buf,
- "FROM pg_catalog.pg_subscription\n"
- "WHERE subdbid = (SELECT oid\n"
- " FROM pg_catalog.pg_database\n"
- " WHERE datname = pg_catalog.current_database())");
-
+ appendPQExpBuffer(&buf,
+ "FROM pg_catalog.pg_subscription "
+ "WHERE subdbid = (SELECT oid\n"
+ " FROM pg_catalog.pg_database\n"
+ " WHERE datname = pg_catalog.current_database())");
if (!validateSQLNamePattern(&buf, pattern, true, false,
NULL, "subname", NULL,
NULL,
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 75a101c6ab5..9419573292d 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -2344,8 +2344,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("(", "PUBLICATION");
/* ALTER SUBSCRIPTION <name> SET ( */
else if (Matches("ALTER", "SUBSCRIPTION", MatchAny, MatchAnyN, "SET", "("))
- COMPLETE_WITH("binary", "disable_on_error", "failover",
- "max_retention_duration", "origin",
+ COMPLETE_WITH("binary", "conflict_log_destination", "disable_on_error",
+ "failover", "max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
"synchronous_commit", "two_phase");
@@ -3851,8 +3851,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("WITH (");
/* Complete "CREATE SUBSCRIPTION <name> ... WITH ( <opt>" */
else if (Matches("CREATE", "SUBSCRIPTION", MatchAnyN, "WITH", "("))
- COMPLETE_WITH("binary", "connect", "copy_data", "create_slot",
- "disable_on_error", "enabled", "failover",
+ COMPLETE_WITH("binary", "conflict_log_destination", "connect", "copy_data",
+ "create_slot", "disable_on_error", "enabled", "failover",
"max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
diff --git a/src/include/catalog/catalog.h b/src/include/catalog/catalog.h
index 9b5e750b7e4..1d545ebac08 100644
--- a/src/include/catalog/catalog.h
+++ b/src/include/catalog/catalog.h
@@ -25,6 +25,7 @@ extern bool IsInplaceUpdateRelation(Relation relation);
extern bool IsSystemClass(Oid relid, Form_pg_class reltuple);
extern bool IsToastClass(Form_pg_class reltuple);
+extern bool IsConflictClass(Form_pg_class reltuple);
extern bool IsCatalogRelationOid(Oid relid);
extern bool IsCatalogTextUniqueIndexOid(Oid relid);
diff --git a/src/include/catalog/pg_namespace.dat b/src/include/catalog/pg_namespace.dat
index 008373728f3..4bb8cd92b71 100644
--- a/src/include/catalog/pg_namespace.dat
+++ b/src/include/catalog/pg_namespace.dat
@@ -18,6 +18,9 @@
{ oid => '99', oid_symbol => 'PG_TOAST_NAMESPACE',
descr => 'reserved schema for TOAST tables',
nspname => 'pg_toast', nspacl => '_null_' },
+{ oid => '1382', oid_symbol => 'PG_CONFLICT_NAMESPACE',
+ descr => 'reserved schema for conflict tables',
+ nspname => 'pg_conflict', nspacl => '_null_' },
# update dumpNamespace() if changing this descr
{ oid => '2200', oid_symbol => 'PG_PUBLIC_NAMESPACE',
descr => 'standard public schema',
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index 55cb9b1eefa..be99094b91c 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -90,6 +90,7 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
* exceeded max_retention_duration, when
* defined */
+ Oid subconflictlogrelid; /* Relid of the conflict log table. */
#ifdef CATALOG_VARLEN /* variable-length fields start here */
/* Connection string to the publisher */
text subconninfo BKI_FORCE_NOT_NULL;
@@ -103,6 +104,12 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
/* List of publications subscribed to */
text subpublications[1] BKI_FORCE_NOT_NULL;
+ /*
+ * Strategy for logging replication conflicts: 'log' - server log only,
+ * 'table' - internal table only, 'all' - both log and table.
+ */
+ text subconflictlogdest;
+
/* Only publish data originating from the specified origin */
text suborigin BKI_DEFAULT(LOGICALREP_ORIGIN_ANY);
#endif
@@ -152,12 +159,14 @@ typedef struct Subscription
* and the retention duration has not
* exceeded max_retention_duration, when
* defined */
+ Oid conflictlogrelid; /* conflict log table Oid */
char *conninfo; /* Connection string to the publisher */
char *slotname; /* Name of the replication slot */
char *synccommit; /* Synchronous commit setting for worker */
List *publications; /* List of publication names to subscribe to */
char *origin; /* Only publish data originating from the
* specified origin */
+ char *conflictlogdest; /* Conflict log destination */
} Subscription;
#ifdef EXPOSE_TO_CLIENT_CODE
diff --git a/src/include/commands/subscriptioncmds.h b/src/include/commands/subscriptioncmds.h
index fb4e26a51a4..fc403cc4c5f 100644
--- a/src/include/commands/subscriptioncmds.h
+++ b/src/include/commands/subscriptioncmds.h
@@ -17,6 +17,7 @@
#include "catalog/objectaddress.h"
#include "parser/parse_node.h"
+#include "replication/conflict.h"
extern ObjectAddress CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bool isTopLevel);
@@ -36,4 +37,7 @@ extern void CheckSubDeadTupleRetention(bool check_guc, bool sub_disabled,
bool retention_active,
bool max_retention_set);
+extern ConflictLogDest GetLogDestination(const char *dest);
+extern bool IsConflictLogTable(Oid relid);
+
#endif /* SUBSCRIPTIONCMDS_H */
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index c8fbf9e51b8..e722df2d015 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -10,6 +10,7 @@
#define CONFLICT_H
#include "access/xlogdefs.h"
+#include "catalog/pg_type.h"
#include "nodes/pg_list.h"
#include "utils/timestamp.h"
@@ -79,6 +80,61 @@ typedef struct ConflictTupleInfo
* conflicting local row occurred */
} ConflictTupleInfo;
+/*
+ * Conflict log destination types.
+ *
+ * These values are defined as bitmask flags to allow for multiple simultaneous
+ * logging destinations (e.g., logging to both system logs and a table).
+ * Internally, we use these for bitwise comparisons (IsSet), but the string
+ * representation is stored in pg_subscription.subconflictlogdest.
+ */
+typedef enum ConflictLogDest
+{
+ /* Log conflicts to the server logs */
+ CONFLICT_LOG_DEST_LOG = 1 << 0, /* 0x01 */
+
+ /* Log conflicts to an internally managed table */
+ CONFLICT_LOG_DEST_TABLE = 1 << 1, /* 0x02 */
+
+ /* Convenience flag for all supported destinations */
+ CONFLICT_LOG_DEST_ALL = (CONFLICT_LOG_DEST_LOG | CONFLICT_LOG_DEST_TABLE)
+} ConflictLogDest;
+
+/*
+ * Array mapping for converting internal enum to string.
+ */
+static const char *const ConflictLogDestNames[] = {
+ [CONFLICT_LOG_DEST_LOG] = "log",
+ [CONFLICT_LOG_DEST_TABLE] = "table",
+ [CONFLICT_LOG_DEST_ALL] = "all"
+};
+
+/* Structure to hold metadata for one column of the conflict log table */
+typedef struct ConflictLogColumnDef
+{
+ const char *attname; /* Column name */
+ Oid atttypid; /* Data type OID */
+} ConflictLogColumnDef;
+
+/* The single source of truth for the conflict log table schema */
+static const ConflictLogColumnDef ConflictLogSchema[] =
+{
+ {.attname = "relid",.atttypid = OIDOID},
+ {.attname = "schemaname",.atttypid = TEXTOID},
+ {.attname = "relname",.atttypid = TEXTOID},
+ {.attname = "conflict_type",.atttypid = TEXTOID},
+ {.attname = "remote_xid",.atttypid = XIDOID},
+ {.attname = "remote_commit_lsn",.atttypid = LSNOID},
+ {.attname = "remote_commit_ts",.atttypid = TIMESTAMPTZOID},
+ {.attname = "remote_origin",.atttypid = TEXTOID},
+ {.attname = "replica_identity",.atttypid = JSONOID},
+ {.attname = "remote_tuple",.atttypid = JSONOID},
+ {.attname = "local_conflicts",.atttypid = JSONARRAYOID}
+};
+
+/* Define the count using the array size */
+#define MAX_CONFLICT_ATTR_NUM (sizeof(ConflictLogSchema) / sizeof(ConflictLogSchema[0]))
+
extern bool GetTupleTransactionInfo(TupleTableSlot *localslot,
TransactionId *xmin,
RepOriginId *localorigin,
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index 327d1e7731f..60c8ef10a8a 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -116,18 +116,18 @@ CREATE SUBSCRIPTION regress_testsub4 CONNECTION 'dbname=regress_doesnotexist' PU
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub4 SET (origin = any);
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub3;
@@ -145,10 +145,10 @@ ALTER SUBSCRIPTION regress_testsub CONNECTION 'foobar';
ERROR: invalid connection string syntax: missing "=" after "foobar" in connection info string
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET PUBLICATION testpub2, testpub3 WITH (refresh = false);
@@ -157,10 +157,10 @@ ALTER SUBSCRIPTION regress_testsub SET (slot_name = 'newname');
ALTER SUBSCRIPTION regress_testsub SET (password_required = false);
ALTER SUBSCRIPTION regress_testsub SET (run_as_owner = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (password_required = true);
@@ -176,10 +176,10 @@ ERROR: unrecognized subscription parameter: "create_slot"
-- ok
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/12345');
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345 | log
(1 row)
-- ok - with lsn = NONE
@@ -188,10 +188,10 @@ ALTER SUBSCRIPTION regress_testsub SKIP (lsn = NONE);
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/0');
ERROR: invalid WAL location (LSN): 0/0
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
BEGIN;
@@ -223,10 +223,10 @@ ALTER SUBSCRIPTION regress_testsub_foo SET (synchronous_commit = foobar);
ERROR: invalid value for parameter "synchronous_commit": "foobar"
HINT: Available values: local, remote_write, remote_apply, on, off.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
----------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+---------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
-- rename back to keep the rest simple
@@ -255,19 +255,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (binary = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -279,27 +279,27 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = parallel);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- fail - publication already exists
@@ -314,10 +314,10 @@ ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refr
ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refresh = false);
ERROR: publication "testpub1" is already in subscription "regress_testsub"
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- fail - publication used more than once
@@ -332,10 +332,10 @@ ERROR: publication "testpub3" is not in subscription "regress_testsub"
-- ok - delete publications
ALTER SUBSCRIPTION regress_testsub DROP PUBLICATION testpub1, testpub2 WITH (refresh = false);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -371,19 +371,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- we can alter streaming when two_phase enabled
ALTER SUBSCRIPTION regress_testsub SET (streaming = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -393,10 +393,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -409,18 +409,18 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (disable_on_error = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -433,10 +433,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -450,19 +450,19 @@ NOTICE: max_retention_duration is ineffective when retain_dead_tuples is disabl
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- ok
ALTER SUBSCRIPTION regress_testsub SET (max_retention_duration = 0);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -517,7 +517,167 @@ COMMIT;
-- ok, owning it is enough for this stuff
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG DESTINATION TESTS
+--
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+-- fail - unrecognized parameter value
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
+ERROR: unrecognized conflict_log_destination value: "invalid"
+HINT: Valid values are "log", "table", and "all".
+-- verify subconflictlogdest is 'log' and relid is 0 (InvalidOid) for default case
+CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
+ subname | subconflictlogdest | subconflictlogrelid
+------------------------------+--------------------+---------------------
+ regress_conflict_log_default | log | 0
+(1 row)
+
+-- verify empty string defaults to 'log'
+CREATE SUBSCRIPTION regress_conflict_empty_str CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = '');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
+ subname | subconflictlogdest | subconflictlogrelid
+----------------------------+--------------------+---------------------
+ regress_conflict_empty_str | log | 0
+(1 row)
+
+-- this should generate an internal table named conflict_log_table_$subid$
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- check metadata in pg_subscription: destination should be 'table' and relid valid
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname | subconflictlogdest | has_relid
+------------------------+--------------------+-----------
+ regress_conflict_test1 | table | t
+(1 row)
+
+-- verify the physical table exists and its OID matches subconflictlogrelid
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND c.oid = s.subconflictlogrelid;
+ count
+-------
+ 1
+(1 row)
+
+-- check if the internal table has the correct schema (11 columns)
+SELECT count(*)
+FROM pg_attribute a
+JOIN pg_class c ON a.attrelid = c.oid
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0;
+ count
+-------
+ 11
+(1 row)
+
+--
+-- ALTER SUBSCRIPTION - conflict_log_destination state transitions
+--
+-- These tests verify the transition logic between different logging
+-- destinations, ensuring internal tables are created or dropped as expected.
+--
+-- transition from 'log' to 'all'
+-- a new internal conflict log table should be created
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
+-- verify metadata after ALTER (destination should be 'all')
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogdest | has_relid
+------------------------+--------------------+-----------
+ regress_conflict_test2 | all | t
+(1 row)
+
+-- transition from 'all' to 'table'
+-- should NOT drop the table, only change destination string
+SELECT subconflictlogrelid AS old_relid FROM pg_subscription WHERE subname = 'regress_conflict_test2' \gset
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'table');
+SELECT subconflictlogdest, subconflictlogrelid = :old_relid AS relid_unchanged
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subconflictlogdest | relid_unchanged
+--------------------+-----------------
+ table | t
+(1 row)
+
+-- transition from 'table' to 'log'
+-- should drop the table and clear relid
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log');
+SELECT subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subconflictlogdest | subconflictlogrelid
+--------------------+---------------------
+ log | 0
+(1 row)
+
+-- verify the physical table is gone
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test2';
+ count
+-------
+ 0
+(1 row)
+
+-- ensure drop table not allowed and DROP SUBSCRIPTION reaps the table
+-- re-enable table logging for verification
+ALTER SUBSCRIPTION regress_conflict_test1 SET (conflict_log_destination = 'table');
+-- fail - drop table not allowed due to internal dependency
+-- use DO block to hide OID in error message
+DO $$
+BEGIN
+ EXECUTE 'DROP TABLE ' || (SELECT 'pg_conflict.conflict_log_table_' || oid FROM pg_subscription WHERE subname = 'regress_conflict_test1');
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege';
+END $$;
+NOTICE: captured expected error: insufficient_privilege
+-- PUBLICATION: Verify internal tables are not publishable
+-- pg_relation_is_publishable should return false for internal conflict log tables
+SELECT pg_relation_is_publishable(subconflictlogrelid)
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ pg_relation_is_publishable
+----------------------------
+ f
+(1 row)
+
+-- CLEANUP: Proper drop reaps the table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+-- Verify the table OID for reap check
+SELECT 'conflict_log_table_' || oid AS internal_tablename FROM pg_subscription WHERE subname = 'regress_conflict_test1' \gset
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the internal table was reaped via dependency
+SELECT to_regclass(:'internal_tablename');
+ to_regclass
+-------------
+
+(1 row)
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_log_default DISABLE;
+ALTER SUBSCRIPTION regress_conflict_log_default SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_log_default;
+ALTER SUBSCRIPTION regress_conflict_empty_str DISABLE;
+ALTER SUBSCRIPTION regress_conflict_empty_str SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_empty_str;
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
RESET SESSION AUTHORIZATION;
+DROP SCHEMA clt;
+ERROR: schema "clt" does not exist
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
DROP ROLE regress_subscription_user3;
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index ef0c298d2df..00951aa8b9a 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -365,7 +365,125 @@ COMMIT;
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG DESTINATION TESTS
+--
+
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+
+-- fail - unrecognized parameter value
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
+
+-- verify subconflictlogdest is 'log' and relid is 0 (InvalidOid) for default case
+CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
+
+-- verify empty string defaults to 'log'
+CREATE SUBSCRIPTION regress_conflict_empty_str CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = '');
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
+
+-- this should generate an internal table named conflict_log_table_$subid$
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+
+-- check metadata in pg_subscription: destination should be 'table' and relid valid
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- verify the physical table exists and its OID matches subconflictlogrelid
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND c.oid = s.subconflictlogrelid;
+
+-- check if the internal table has the correct schema (11 columns)
+SELECT count(*)
+FROM pg_attribute a
+JOIN pg_class c ON a.attrelid = c.oid
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0;
+
+--
+-- ALTER SUBSCRIPTION - conflict_log_destination state transitions
+--
+-- These tests verify the transition logic between different logging
+-- destinations, ensuring internal tables are created or dropped as expected.
+--
+
+-- transition from 'log' to 'all'
+-- a new internal conflict log table should be created
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
+
+-- verify metadata after ALTER (destination should be 'all')
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'all' to 'table'
+-- should NOT drop the table, only change destination string
+SELECT subconflictlogrelid AS old_relid FROM pg_subscription WHERE subname = 'regress_conflict_test2' \gset
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'table');
+SELECT subconflictlogdest, subconflictlogrelid = :old_relid AS relid_unchanged
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'table' to 'log'
+-- should drop the table and clear relid
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log');
+SELECT subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- verify the physical table is gone
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'conflict_log_table_' || s.oid
+WHERE s.subname = 'regress_conflict_test2';
+
+-- ensure drop table not allowed and DROP SUBSCRIPTION reaps the table
+-- re-enable table logging for verification
+ALTER SUBSCRIPTION regress_conflict_test1 SET (conflict_log_destination = 'table');
+
+-- fail - drop table not allowed due to internal dependency
+-- use DO block to hide OID in error message
+DO $$
+BEGIN
+ EXECUTE 'DROP TABLE ' || (SELECT 'pg_conflict.conflict_log_table_' || oid FROM pg_subscription WHERE subname = 'regress_conflict_test1');
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege';
+END $$;
+
+-- PUBLICATION: Verify internal tables are not publishable
+-- pg_relation_is_publishable should return false for internal conflict log tables
+SELECT pg_relation_is_publishable(subconflictlogrelid)
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- CLEANUP: Proper drop reaps the table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+
+-- Verify the table OID for reap check
+SELECT 'conflict_log_table_' || oid AS internal_tablename FROM pg_subscription WHERE subname = 'regress_conflict_test1' \gset
+
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the internal table was reaped via dependency
+SELECT to_regclass(:'internal_tablename');
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_log_default DISABLE;
+ALTER SUBSCRIPTION regress_conflict_log_default SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_log_default;
+
+ALTER SUBSCRIPTION regress_conflict_empty_str DISABLE;
+ALTER SUBSCRIPTION regress_conflict_empty_str SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_empty_str;
+
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+
RESET SESSION AUTHORIZATION;
+DROP SCHEMA clt;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
DROP ROLE regress_subscription_user3;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 5c88fa92f4e..0013af89cb7 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -500,6 +500,8 @@ ConditionVariableMinimallyPadded
ConditionalStack
ConfigData
ConfigVariable
+ConflictLogColumnDef
+ConflictLogDest
ConflictTupleInfo
ConflictType
ConnCacheEntry
--
2.43.0
[text/x-patch] v17-0005-Preserve-conflict-log-destination-for-subscripti.patch (13.1K, ../../CALDaNm1aivk9KgQ5daeF6YZzuE+0wWc2yb7wb6qikNyvfPN0Sg@mail.gmail.com/4-v17-0005-Preserve-conflict-log-destination-for-subscripti.patch)
download | inline diff:
From a1d71b15ea385087ea21530e4262434d6c2f35ef Mon Sep 17 00:00:00 2001
From: Vignesh C <vignesh21@gmail.com>
Date: Fri, 26 Dec 2025 20:18:13 +0530
Subject: [PATCH v17 5/6] Preserve conflict log destination for subscriptions
Support pg_dump to dump and restore the conflict_log_destination setting for
subscriptions.
During a normal CREATE SUBSCRIPTION, a conflict log table is created
automatically when required. However, during dump/restore or binary upgrade,
the conflict log table may already exist and must be reused rather than
recreated.
To ensure correct behavior, pg_dump now emits an ALTER SUBSCRIPTION command
after subscription creation to restore the conflict_log_destination setting.
While dumping, pg_dump temporarily sets the search_path to the schema in which
the conflict log table was created, ensuring that the conflict log table is
resolved with the appropriate schema.
---
src/backend/commands/subscriptioncmds.c | 143 ++++++++++++++++++------
src/bin/pg_dump/pg_dump.c | 47 +++++++-
src/bin/pg_dump/pg_dump.h | 2 +
src/bin/pg_dump/pg_dump_sort.c | 31 +++++
src/bin/pg_dump/t/002_pg_dump.pl | 5 +-
5 files changed, 188 insertions(+), 40 deletions(-)
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index dcbf192be24..fb48d64801b 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -1378,6 +1378,109 @@ CheckAlterSubOption(Subscription *sub, const char *option,
}
}
+/*
+ * AlterSubscriptionConflictLogDestination
+ *
+ * Update the conflict log table associated with a subscription when its
+ * conflict log destination is changed.
+ *
+ * If the new destination requires a conflict log table and none was previously
+ * required, this function validates an existing conflict log table identified
+ * by the subscription specific naming convention or creates a new one.
+ *
+ * If the new destination no longer requires a conflict log table, the existing
+ * conflict log table associated with the subscription is removed via internal
+ * dependency cleanup to prevent orphaned relations.
+ *
+ * The function enforces that any conflict log table used is a permanent
+ * relation in a permanent schema, matches the expected structure, and is not
+ * already associated with another subscription.
+ *
+ * On success, *conflicttablerelid is set to the OID of the conflict log table
+ * that was created or validated, or to InvalidOid if no table is required.
+ *
+ * Returns true if the subscription's conflict log table reference must be
+ * updated as a result of the destination change; false otherwise.
+ */
+static bool
+AlterSubscriptionConflictLogDestination(Subscription *sub,
+ ConflictLogDest logdest,
+ Oid *conflicttablerelid)
+{
+ ConflictLogDest old_dest = GetLogDestination(sub->conflictlogdest);
+ bool want_table;
+ bool has_oldtable;
+ bool update_relid = false;
+ Oid relid = InvalidOid;
+
+ want_table = IsSet(logdest, CONFLICT_LOG_DEST_TABLE);
+ has_oldtable = IsSet(old_dest, CONFLICT_LOG_DEST_TABLE);
+
+ if (want_table && !has_oldtable)
+ {
+ char relname[NAMEDATALEN];
+
+ snprintf(relname, NAMEDATALEN, "conflict_log_table_%u", sub->oid);
+
+ relid = get_relname_relid(relname, PG_CONFLICT_NAMESPACE);
+ if (OidIsValid(relid))
+ {
+ Relation conflictlogrel;
+ char *nspname = get_namespace_name(PG_CONFLICT_NAMESPACE);
+
+ conflictlogrel = table_open(relid, RowExclusiveLock);
+ if (conflictlogrel->rd_rel->relpersistence != RELPERSISTENCE_PERMANENT)
+ ereport(ERROR,
+ errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("conflict log table \"%s.%s\" must be a permanent table",
+ nspname, relname),
+ errhint("Specify a permanent table as the conflict log table."));
+
+ if (IsConflictLogTable(relid))
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("conflict log table \"%s.%s\" cannot be used",
+ nspname, relname),
+ errdetail("The specified table is already registered for a different subscription."),
+ errhint("Specify a different conflict log table."));
+ if (!ValidateConflictLogTable(conflictlogrel))
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("conflict log table \"%s.%s\" has an incompatible definition",
+ nspname, relname),
+ errdetail("The table does not match the required conflict log table structure."),
+ errhint("Create the conflict log table with the expected definition or specify a different table."));
+
+ table_close(conflictlogrel, NoLock);
+ }
+ else
+ relid = create_conflict_log_table(sub->oid, sub->name);
+ update_relid = true;
+ }
+ else if (!want_table && has_oldtable)
+ {
+ ObjectAddress object;
+
+ /*
+ * Conflict log tables are recorded as internal dependencies of the
+ * subscription. Drop the table if it is not required anymore to
+ * avoid stale or orphaned relations.
+ *
+ * XXX: At present, only conflict log tables are managed this way. In
+ * future if we introduce additional internal dependencies, we may
+ * need a targeted deletion to avoid deletion of any other objects.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId, sub->oid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+ update_relid = true;
+ }
+
+ *conflicttablerelid = relid;
+ return update_relid;
+}
+
/*
* Alter the existing subscription.
*/
@@ -1725,53 +1828,21 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
if (opts.logdest != old_dest)
{
- bool want_table =
- IsSet(opts.logdest, CONFLICT_LOG_DEST_TABLE);
- bool has_oldtable =
- IsSet(old_dest, CONFLICT_LOG_DEST_TABLE);
+ bool update_relid;
+ Oid relid = InvalidOid;
values[Anum_pg_subscription_subconflictlogdest - 1] =
CStringGetTextDatum(ConflictLogDestNames[opts.logdest]);
replaces[Anum_pg_subscription_subconflictlogdest - 1] = true;
- if (want_table && !has_oldtable)
+ update_relid = AlterSubscriptionConflictLogDestination(sub, opts.logdest, &relid);
+ if (update_relid)
{
- Oid relid;
-
- relid = create_conflict_log_table(subid, sub->name);
-
values[Anum_pg_subscription_subconflictlogrelid - 1] =
ObjectIdGetDatum(relid);
replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
true;
}
- else if (!want_table && has_oldtable)
- {
- ObjectAddress object;
-
- /*
- * Conflict log tables are recorded as internal
- * dependencies of the subscription. Drop the
- * table if it is not required anymore to avoid
- * stale or orphaned relations.
- *
- * XXX: At present, only conflict log tables are
- * managed this way. In future if we introduce
- * additional internal dependencies, we may need a
- * targeted deletion to avoid deletion of any
- * other objects.
- */
- ObjectAddressSet(object, SubscriptionRelationId,
- subid);
- performDeletion(&object, DROP_CASCADE,
- PERFORM_DELETION_INTERNAL |
- PERFORM_DELETION_SKIP_ORIGINAL);
-
- values[Anum_pg_subscription_subconflictlogrelid - 1] =
- ObjectIdGetDatum(InvalidOid);
- replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
- true;
- }
}
}
diff --git a/src/bin/pg_dump/pg_dump.c b/src/bin/pg_dump/pg_dump.c
index 27f6be3f0f8..3bd2eef66e6 100644
--- a/src/bin/pg_dump/pg_dump.c
+++ b/src/bin/pg_dump/pg_dump.c
@@ -5130,6 +5130,8 @@ getSubscriptions(Archive *fout)
int i_subfailover;
int i_subretaindeadtuples;
int i_submaxretention;
+ int i_subconflictlogrelid;
+ int i_sublogdestination;
int i,
ntups;
@@ -5216,10 +5218,17 @@ getSubscriptions(Archive *fout)
if (fout->remoteVersion >= 190000)
appendPQExpBufferStr(query,
- " s.submaxretention\n");
+ " s.submaxretention,\n");
else
appendPQExpBuffer(query,
- " 0 AS submaxretention\n");
+ " 0 AS submaxretention,\n");
+
+ if (fout->remoteVersion >= 190000)
+ appendPQExpBufferStr(query,
+ " s.subconflictlogrelid, s.subconflictlogdest\n");
+ else
+ appendPQExpBufferStr(query,
+ " NULL AS subconflictlogrelid, NULL AS subconflictlogdest\n");
appendPQExpBufferStr(query,
"FROM pg_subscription s\n");
@@ -5261,6 +5270,8 @@ getSubscriptions(Archive *fout)
i_subpublications = PQfnumber(res, "subpublications");
i_suborigin = PQfnumber(res, "suborigin");
i_suboriginremotelsn = PQfnumber(res, "suboriginremotelsn");
+ i_subconflictlogrelid = PQfnumber(res, "subconflictlogrelid");
+ i_sublogdestination = PQfnumber(res, "subconflictlogdest");
subinfo = pg_malloc(ntups * sizeof(SubscriptionInfo));
@@ -5309,6 +5320,33 @@ getSubscriptions(Archive *fout)
else
subinfo[i].suboriginremotelsn =
pg_strdup(PQgetvalue(res, i, i_suboriginremotelsn));
+ if (PQgetisnull(res, i, i_subconflictlogrelid))
+ subinfo[i].subconflictlogrelid = InvalidOid;
+ else
+ {
+ TableInfo *tableInfo;
+
+ subinfo[i].subconflictlogrelid =
+ atooid(PQgetvalue(res, i, i_subconflictlogrelid));
+
+ if (subinfo[i].subconflictlogrelid)
+ {
+ tableInfo = findTableByOid(subinfo[i].subconflictlogrelid);
+ if (!tableInfo)
+ pg_fatal("could not find conflict log table with OID %u",
+ subinfo[i].subconflictlogrelid);
+
+ addObjectDependency(&subinfo[i].dobj, tableInfo->dobj.dumpId);
+
+ if (!dopt->binary_upgrade)
+ tableInfo->dobj.dump = DUMP_COMPONENT_NONE;
+ }
+ }
+ if (PQgetisnull(res, i, i_sublogdestination))
+ subinfo[i].subconflictlogdest = NULL;
+ else
+ subinfo[i].subconflictlogdest =
+ pg_strdup(PQgetvalue(res, i, i_sublogdestination));
/* Decide whether we want to dump it */
selectDumpableObject(&(subinfo[i].dobj), fout);
@@ -5564,6 +5602,11 @@ dumpSubscription(Archive *fout, const SubscriptionInfo *subinfo)
appendPQExpBufferStr(query, ");\n");
+ appendPQExpBuffer(query,
+ "\n\nALTER SUBSCRIPTION %s SET (conflict_log_destination = %s);\n",
+ qsubname,
+ subinfo->subconflictlogdest);
+
/*
* In binary-upgrade mode, we allow the replication to continue after the
* upgrade.
diff --git a/src/bin/pg_dump/pg_dump.h b/src/bin/pg_dump/pg_dump.h
index 72a00e1bc20..c6273049f63 100644
--- a/src/bin/pg_dump/pg_dump.h
+++ b/src/bin/pg_dump/pg_dump.h
@@ -719,12 +719,14 @@ typedef struct _SubscriptionInfo
bool subfailover;
bool subretaindeadtuples;
int submaxretention;
+ Oid subconflictlogrelid;
char *subconninfo;
char *subslotname;
char *subsynccommit;
char *subpublications;
char *suborigin;
char *suboriginremotelsn;
+ char *subconflictlogdest;
} SubscriptionInfo;
/*
diff --git a/src/bin/pg_dump/pg_dump_sort.c b/src/bin/pg_dump/pg_dump_sort.c
index e2a4df4cf4b..2f170cae70f 100644
--- a/src/bin/pg_dump/pg_dump_sort.c
+++ b/src/bin/pg_dump/pg_dump_sort.c
@@ -1131,6 +1131,19 @@ repairTableAttrDefMultiLoop(DumpableObject *tableobj,
addObjectDependency(attrdefobj, tableobj->dumpId);
}
+/*
+ * Because we make subscriptions depend on their conflict log tables, while
+ * there is an automatic dependency in the other direction, we need to break
+ * the loop. Remove the automatic dependency, allowing the table to be created
+ * first.
+ */
+static void
+repairSubscriptionTableLoop(DumpableObject *subobj, DumpableObject *tableobj)
+{
+ /* Remove table's dependency on subscription */
+ removeObjectDependency(tableobj, subobj->dumpId);
+}
+
/*
* CHECK, NOT NULL constraints on domains work just like those on tables ...
*/
@@ -1361,6 +1374,24 @@ repairDependencyLoop(DumpableObject **loop,
return;
}
+ /*
+ * Subscription and its Conflict Log Table
+ */
+ if (nLoop == 2 &&
+ loop[0]->objType == DO_TABLE &&
+ loop[1]->objType == DO_SUBSCRIPTION)
+ {
+ repairSubscriptionTableLoop(loop[1], loop[0]);
+ return;
+ }
+ if (nLoop == 2 &&
+ loop[0]->objType == DO_SUBSCRIPTION &&
+ loop[1]->objType == DO_TABLE)
+ {
+ repairSubscriptionTableLoop(loop[0], loop[1]);
+ return;
+ }
+
/* index on partitioned table and corresponding index on partition */
if (nLoop == 2 &&
loop[0]->objType == DO_INDEX &&
diff --git a/src/bin/pg_dump/t/002_pg_dump.pl b/src/bin/pg_dump/t/002_pg_dump.pl
index e33aa95f6ff..1023bbf2d1d 100644
--- a/src/bin/pg_dump/t/002_pg_dump.pl
+++ b/src/bin/pg_dump/t/002_pg_dump.pl
@@ -3204,9 +3204,10 @@ my %tests = (
create_order => 50,
create_sql => 'CREATE SUBSCRIPTION sub3
CONNECTION \'dbname=doesnotexist\' PUBLICATION pub1
- WITH (connect = false, origin = any, streaming = on);',
+ WITH (connect = false, origin = any, streaming = on, conflict_log_destination= table);',
regexp => qr/^
- \QCREATE SUBSCRIPTION sub3 CONNECTION 'dbname=doesnotexist' PUBLICATION pub1 WITH (connect = false, slot_name = 'sub3', streaming = on);\E
+ \QCREATE SUBSCRIPTION sub3 CONNECTION 'dbname=doesnotexist' PUBLICATION pub1 WITH (connect = false, slot_name = 'sub3', streaming = on);\E\n\n\n
+ \QALTER SUBSCRIPTION sub3 SET (conflict_log_destination = table);\E
/xm,
like => { %full_runs, section_post_data => 1, },
unlike => {
--
2.43.0
[text/x-patch] v17-0004-Add-shared-index-for-conflict-log-table-lookup.patch (13.0K, ../../CALDaNm1aivk9KgQ5daeF6YZzuE+0wWc2yb7wb6qikNyvfPN0Sg@mail.gmail.com/5-v17-0004-Add-shared-index-for-conflict-log-table-lookup.patch)
download | inline diff:
From 389ecbfd23e3b4c11570bdab1d3aec789781cfcf Mon Sep 17 00:00:00 2001
From: Vignesh C <vignesh21@gmail.com>
Date: Fri, 26 Dec 2025 20:14:27 +0530
Subject: [PATCH v17 4/6] Add shared index for conflict log table lookup
Introduce a dedicated shared unique index on
pg_subscription.subconflictlogrelid to make conflict log table
detection efficient, and index-backed. Previously, IsConflictLogTable()
relied on a full catalog scan of pg_subscription, which was inefficient.
This change adds pg_subscription_conflictrel_index and marks it as a
shared index, matching the shared pg_subscription table, and rewrites
conflict log table detection to use an indexed systable scan.
---
src/backend/catalog/catalog.c | 1 +
src/backend/catalog/pg_publication.c | 12 +----------
src/backend/catalog/pg_subscription.c | 23 +++++++++++++++++++++
src/backend/commands/subscriptioncmds.c | 21 ++++++++++++-------
src/backend/replication/logical/conflict.c | 4 +---
src/backend/replication/pgoutput/pgoutput.c | 15 +++++++++++---
src/bin/psql/describe.c | 4 +++-
src/include/catalog/pg_proc.dat | 7 +++++++
src/include/catalog/pg_subscription.h | 1 +
src/test/regress/expected/subscription.out | 7 ++++---
src/test/regress/sql/subscription.sql | 5 +++--
11 files changed, 69 insertions(+), 31 deletions(-)
diff --git a/src/backend/catalog/catalog.c b/src/backend/catalog/catalog.c
index e5f57d4aa69..220be919999 100644
--- a/src/backend/catalog/catalog.c
+++ b/src/backend/catalog/catalog.c
@@ -349,6 +349,7 @@ IsSharedRelation(Oid relationId)
relationId == SharedSecLabelObjectIndexId ||
relationId == SubscriptionNameIndexId ||
relationId == SubscriptionObjectIndexId ||
+ relationId == SubscriptionConflictrelIndexId ||
relationId == TablespaceNameIndexId ||
relationId == TablespaceOidIndexId)
return true;
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index afa2b85875e..187eb351f3f 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -86,15 +86,6 @@ check_publication_add_relation(Relation targetrel)
errmsg("cannot add relation \"%s\" to publication",
RelationGetRelationName(targetrel)),
errdetail("This operation is not supported for unlogged tables.")));
-
- /* Can't be conflict log table */
- if (IsConflictLogTable(RelationGetRelid(targetrel)))
- ereport(ERROR,
- (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
- errmsg("cannot add relation \"%s.%s\" to publication",
- get_namespace_name(RelationGetNamespace(targetrel)),
- RelationGetRelationName(targetrel)),
- errdetail("This operation is not supported for conflict log tables.")));
}
/*
@@ -188,8 +179,7 @@ pg_relation_is_publishable(PG_FUNCTION_ARGS)
PG_RETURN_NULL();
/* Subscription conflict log tables are not published */
- result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple)) &&
- !IsConflictLogTable(relid);
+ result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple));
ReleaseSysCache(tuple);
PG_RETURN_BOOL(result);
}
diff --git a/src/backend/catalog/pg_subscription.c b/src/backend/catalog/pg_subscription.c
index 842cc417e68..5a0e5db1c03 100644
--- a/src/backend/catalog/pg_subscription.c
+++ b/src/backend/catalog/pg_subscription.c
@@ -22,6 +22,7 @@
#include "catalog/pg_subscription.h"
#include "catalog/pg_subscription_rel.h"
#include "catalog/pg_type.h"
+#include "commands/subscriptioncmds.h"
#include "miscadmin.h"
#include "storage/lmgr.h"
#include "utils/array.h"
@@ -156,6 +157,28 @@ GetSubscription(Oid subid, bool missing_ok)
return sub;
}
+/*
+ * pg_relation_is_conflict_log_table
+ *
+ * Returns true if the given relation OID is used as a conflict log table
+ * by any subscription, else returns false.
+ */
+Datum
+pg_relation_is_conflict_log_table(PG_FUNCTION_ARGS)
+{
+ Oid relid = PG_GETARG_OID(0);
+ HeapTuple tuple;
+ bool result;
+
+ tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
+ if (!HeapTupleIsValid(tuple))
+ PG_RETURN_NULL();
+
+ result = IsConflictLogTable(relid);
+ ReleaseSysCache(tuple);
+ PG_RETURN_BOOL(result);
+}
+
/*
* Return number of subscriptions defined in given database.
* Used by dropdb() to check if database can indeed be dropped.
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index c54bcb97407..dcbf192be24 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -53,6 +53,7 @@
#include "storage/lmgr.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/guc.h"
#include "utils/lsyscache.h"
#include "utils/memutils.h"
@@ -3447,27 +3448,31 @@ bool
IsConflictLogTable(Oid relid)
{
Relation rel;
- TableScanDesc scan;
+ ScanKeyData scankey;
+ SysScanDesc scan;
HeapTuple tup;
bool is_clt = false;
rel = table_open(SubscriptionRelationId, AccessShareLock);
- scan = table_beginscan_catalog(rel, 0, NULL);
-
- while (HeapTupleIsValid(tup = heap_getnext(scan, ForwardScanDirection)))
+ ScanKeyInit(&scankey,
+ Anum_pg_subscription_subconflictlogrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(relid));
+
+ scan = systable_beginscan(rel, SubscriptionConflictrelIndexId,
+ true, NULL, 1, &scankey);
+ while (HeapTupleIsValid(tup = systable_getnext(scan)))
{
Form_pg_subscription subform = (Form_pg_subscription) GETSTRUCT(tup);
- /* Direct Oid comparison from catalog */
- if (OidIsValid(subform->subconflictlogrelid) &&
- subform->subconflictlogrelid == relid)
+ if (OidIsValid(subform->subconflictlogrelid))
{
is_clt = true;
break;
}
}
- table_endscan(scan);
+ systable_endscan(scan);
table_close(rel, AccessShareLock);
return is_clt;
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 98080fd2db0..d7fe6e40b2f 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -304,13 +304,11 @@ GetConflictLogTableInfo(ConflictLogDest *log_dest)
void
InsertConflictLogTuple(Relation conflictlogrel)
{
- int options = HEAP_INSERT_NO_LOGICAL;
-
/* A valid tuple must be prepared and stored in MyLogicalRepWorker. */
Assert(MyLogicalRepWorker->conflict_log_tuple != NULL);
heap_insert(conflictlogrel, MyLogicalRepWorker->conflict_log_tuple,
- GetCurrentCommandId(true), options, NULL);
+ GetCurrentCommandId(true), 0, NULL);
/* Free conflict log tuple. */
heap_freetuple(MyLogicalRepWorker->conflict_log_tuple);
diff --git a/src/backend/replication/pgoutput/pgoutput.c b/src/backend/replication/pgoutput/pgoutput.c
index 787998abb8a..5b3375c49a8 100644
--- a/src/backend/replication/pgoutput/pgoutput.c
+++ b/src/backend/replication/pgoutput/pgoutput.c
@@ -2098,6 +2098,7 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
bool am_partition = get_rel_relispartition(relid);
char relkind = get_rel_relkind(relid);
List *rel_publications = NIL;
+ bool isconflictlogrel;
/* Reload publications if needed before use. */
if (!publications_valid)
@@ -2176,6 +2177,9 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
entry->estate = NULL;
memset(entry->exprstate, 0, sizeof(entry->exprstate));
+ /* is this relation used for conflict logging? */
+ isconflictlogrel = IsConflictLogTable(relid);
+
/*
* Build publication cache. We can't use one provided by relcache as
* relcache considers all publications that the given relation is in,
@@ -2199,7 +2203,7 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
* If this is a FOR ALL TABLES publication, pick the partition
* root and set the ancestor level accordingly.
*/
- if (pub->alltables)
+ if (pub->alltables && !isconflictlogrel)
{
publish = true;
if (pub->pubviaroot && am_partition)
@@ -2225,8 +2229,12 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
{
Oid ancestor;
int level;
- List *ancestors = get_partition_ancestors(relid);
+ List *ancestors;
+
+ /* Conflict log table cannot be a partition */
+ Assert(isconflictlogrel == false);
+ ancestors = get_partition_ancestors(relid);
ancestor = GetTopMostAncestorInPublication(pub->oid,
ancestors,
&level);
@@ -2243,7 +2251,8 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
}
if (list_member_oid(pubids, pub->oid) ||
- list_member_oid(schemaPubids, pub->oid) ||
+ (list_member_oid(schemaPubids, pub->oid) &&
+ !isconflictlogrel) ||
ancestor_published)
publish = true;
}
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index d21e3d95506..c10df311c45 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -3063,6 +3063,7 @@ describeOneTableDetails(const char *schemaname,
" JOIN pg_catalog.pg_publication_namespace pn ON p.oid = pn.pnpubid\n"
" JOIN pg_catalog.pg_class pc ON pc.relnamespace = pn.pnnspid\n"
"WHERE pc.oid ='%s' and pg_catalog.pg_relation_is_publishable('%s')\n"
+ "AND NOT pg_catalog.pg_relation_is_conflict_log_table('%s'::oid)\n"
"UNION\n"
"SELECT pubname\n"
" , pg_get_expr(pr.prqual, c.oid)\n"
@@ -3082,8 +3083,9 @@ describeOneTableDetails(const char *schemaname,
" , NULL\n"
"FROM pg_catalog.pg_publication p\n"
"WHERE p.puballtables AND pg_catalog.pg_relation_is_publishable('%s')\n"
+ "AND NOT pg_catalog.pg_relation_is_conflict_log_table('%s'::oid)\n"
"ORDER BY 1;",
- oid, oid, oid, oid);
+ oid, oid, oid, oid, oid, oid);
}
else
{
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index fd9448ec7b9..98fe8eee012 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12321,6 +12321,13 @@
prorettype => 'bool', proargtypes => 'regclass',
prosrc => 'pg_relation_is_publishable' },
+# subscriptions
+{ oid => '6123',
+ descr => 'returns whether a relation is a subscription conflict log table',
+ proname => 'pg_relation_is_conflict_log_table', provolatile => 's',
+ prorettype => 'bool', proargtypes => 'regclass',
+ prosrc => 'pg_relation_is_conflict_log_table' },
+
# rls
{ oid => '3298',
descr => 'row security for current context active on table by table oid',
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index be99094b91c..3328ff919f6 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -121,6 +121,7 @@ DECLARE_TOAST_WITH_MACRO(pg_subscription, 4183, 4184, PgSubscriptionToastTable,
DECLARE_UNIQUE_INDEX_PKEY(pg_subscription_oid_index, 6114, SubscriptionObjectIndexId, pg_subscription, btree(oid oid_ops));
DECLARE_UNIQUE_INDEX(pg_subscription_subname_index, 6115, SubscriptionNameIndexId, pg_subscription, btree(subdbid oid_ops, subname name_ops));
+DECLARE_UNIQUE_INDEX(pg_subscription_conflictrel_index, 6122, SubscriptionConflictrelIndexId, pg_subscription, btree(oid oid_ops, subconflictlogrelid oid_ops));
MAKE_SYSCACHE(SUBSCRIPTIONOID, pg_subscription_oid_index, 4);
MAKE_SYSCACHE(SUBSCRIPTIONNAME, pg_subscription_subname_index, 4);
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index 60c8ef10a8a..934fad364b2 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -643,13 +643,14 @@ EXCEPTION WHEN insufficient_privilege THEN
RAISE NOTICE 'captured expected error: insufficient_privilege';
END $$;
NOTICE: captured expected error: insufficient_privilege
--- PUBLICATION: Verify internal tables are not publishable
--- pg_relation_is_publishable should return false for internal conflict log tables
+-- PUBLICATION: Verify internal tables are publishable
+-- pg_relation_is_publishable should return true for internal conflict log
+-- tables, as it can be published using TABLE publication.
SELECT pg_relation_is_publishable(subconflictlogrelid)
FROM pg_subscription WHERE subname = 'regress_conflict_test1';
pg_relation_is_publishable
----------------------------
- f
+ t
(1 row)
-- CLEANUP: Proper drop reaps the table
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index 00951aa8b9a..c88fb405711 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -452,8 +452,9 @@ EXCEPTION WHEN insufficient_privilege THEN
RAISE NOTICE 'captured expected error: insufficient_privilege';
END $$;
--- PUBLICATION: Verify internal tables are not publishable
--- pg_relation_is_publishable should return false for internal conflict log tables
+-- PUBLICATION: Verify internal tables are publishable
+-- pg_relation_is_publishable should return true for internal conflict log
+-- tables, as it can be published using TABLE publication.
SELECT pg_relation_is_publishable(subconflictlogrelid)
FROM pg_subscription WHERE subname = 'regress_conflict_test1';
--
2.43.0
[text/x-patch] v17-0002-Implement-the-conflict-insertion-infrastructure-.patch (34.5K, ../../CALDaNm1aivk9KgQ5daeF6YZzuE+0wWc2yb7wb6qikNyvfPN0Sg@mail.gmail.com/6-v17-0002-Implement-the-conflict-insertion-infrastructure-.patch)
download | inline diff:
From 82c2eadc602ae4f01dfa09dd943bad2ab600cdcd Mon Sep 17 00:00:00 2001
From: Vignesh C <vignesh21@gmail.com>
Date: Fri, 26 Dec 2025 20:08:11 +0530
Subject: [PATCH v17 2/6] Implement the conflict insertion infrastructure for
the conflict log table
This patch introduces the core logic to populate the conflict log table whenever
a logical replication conflict is detected. It captures the remote transaction
details along with the corresponding local state at the time of the conflict.
Handling Multi-row Conflicts: A single remote tuple may conflict with multiple
local tuples (e.g., in the case of multiple_unique_conflicts). To handle this,
the infrastructure creates a single row in the conflict log table for each
remote tuple. The details of all conflicting local rows are aggregated into a
single JSON array in the local_conflicts column.
The JSON array uses the following structured format:
[ { "xid": "1001", "commit_ts": "2025-12-25 10:00:00+05:30", "origin": "node_1",
"key": {"id": 1}, "tuple": {"id": 1, "val": "old_data"} }, ... ]
Example of querying the structured conflict data:
SELECT remote_xid, relname, remote_origin, local_conflicts[1] ->> 'xid' AS local_xid,
local_conflicts[1] ->> 'tuple' AS local_tuple
FROM myschema.conflict_log_history2;
remote_xid | relname | remote_origin | local_xid | local_tuple
------------+----------+---------------+-----------+---------------------
760 | test | pg_16406 | 771 | {"a":1,"b":10}
765 | conf_tab | pg_16406 | 775 | {"a":2,"b":2,"c":2}
---
src/backend/replication/logical/conflict.c | 603 ++++++++++++++++++-
src/backend/replication/logical/launcher.c | 1 +
src/backend/replication/logical/worker.c | 32 +-
src/include/replication/conflict.h | 4 +-
src/include/replication/worker_internal.h | 7 +
src/test/subscription/t/037_conflict_dest.pl | 190 ++++++
6 files changed, 805 insertions(+), 32 deletions(-)
create mode 100644 src/test/subscription/t/037_conflict_dest.pl
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 16695592265..98080fd2db0 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -15,13 +15,20 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/tableam.h"
+#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "pgstat.h"
#include "replication/conflict.h"
#include "replication/worker_internal.h"
#include "storage/lmgr.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/lsyscache.h"
+#include "utils/pg_lsn.h"
+#include "utils/jsonb.h"
static const char *const ConflictTypeNames[] = {
[CT_INSERT_EXISTS] = "insert_exists",
@@ -34,6 +41,19 @@ static const char *const ConflictTypeNames[] = {
[CT_MULTIPLE_UNIQUE_CONFLICTS] = "multiple_unique_conflicts"
};
+/* Schema for the elements within the 'local_conflicts' JSON array */
+static const ConflictLogColumnDef LocalConflictSchema[] =
+{
+ {.attname = "xid",.atttypid = XIDOID},
+ {.attname = "commit_ts",.atttypid = TIMESTAMPTZOID},
+ {.attname = "origin",.atttypid = TEXTOID},
+ {.attname = "key",.atttypid = JSONOID},
+ {.attname = "tuple",.atttypid = JSONOID}
+};
+
+#define MAX_LOCAL_CONFLICT_INFO_ATTRS \
+ (sizeof(LocalConflictSchema) / sizeof(LocalConflictSchema[0]))
+
static int errcode_apply_conflict(ConflictType type);
static void errdetail_apply_conflict(EState *estate,
ResultRelInfo *relinfo,
@@ -50,8 +70,27 @@ static char *build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *localslot,
TupleTableSlot *remoteslot,
Oid indexoid);
+static void build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull);
static char *build_index_value_desc(EState *estate, Relation localrel,
TupleTableSlot *slot, Oid indexoid);
+static Datum tuple_table_slot_to_json_datum(TupleTableSlot *slot);
+static Datum tuple_table_slot_to_indextup_json(EState *estate,
+ Relation localrel,
+ Oid replica_index,
+ TupleTableSlot *slot);
+static TupleDesc build_conflict_tupledesc(void);
+static Datum build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples);
+static void prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot);
/*
* Get the xmin and commit timestamp data (origin and timestamp) associated
@@ -107,9 +146,17 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
{
Relation localrel = relinfo->ri_RelationDesc;
StringInfoData err_detail;
+ ConflictLogDest dest;
+ Relation conflictlogrel;
initStringInfo(&err_detail);
+ /*
+ * Get both the conflict log destination and the opened conflict log
+ * relation for insertion.
+ */
+ conflictlogrel = GetConflictLogTableInfo(&dest);
+
/* Form errdetail message by combining conflicting tuples information. */
foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
errdetail_apply_conflict(estate, relinfo, type, searchslot,
@@ -120,15 +167,62 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
conflicttuple->ts,
&err_detail);
+ /* Insert to table if destination is 'table' or 'all' */
+ if (conflictlogrel)
+ {
+ Assert((dest & CONFLICT_LOG_DEST_TABLE) != 0);
+
+ if (ValidateConflictLogTable(conflictlogrel))
+ {
+ /*
+ * Prepare the conflict log tuple. If the error level is below
+ * ERROR, insert it immediately. Otherwise, defer the insertion to
+ * a new transaction after the current one aborts, ensuring the
+ * insertion of the log tuple is not rolled back.
+ */
+ prepare_conflict_log_tuple(estate,
+ relinfo->ri_RelationDesc,
+ conflictlogrel,
+ type,
+ searchslot,
+ conflicttuples,
+ remoteslot);
+ if (elevel < ERROR)
+ InsertConflictLogTuple(conflictlogrel);
+ }
+
+ table_close(conflictlogrel, RowExclusiveLock);
+ }
+
pgstat_report_subscription_conflict(MySubscription->oid, type);
- ereport(elevel,
- errcode_apply_conflict(type),
- errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
- get_namespace_name(RelationGetNamespace(localrel)),
- RelationGetRelationName(localrel),
- ConflictTypeNames[type]),
- errdetail_internal("%s", err_detail.data));
+ /* Decide what detail to show in server logs. */
+ if (dest == CONFLICT_LOG_DEST_LOG || dest == CONFLICT_LOG_DEST_ALL)
+ {
+ /* Standard reporting with full internal details. */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail_internal("%s", err_detail.data));
+ }
+ else
+ {
+ /*
+ * 'table' only: Report the error msg but omit raw tuple data from
+ * server logs since it's already captured in the internal table.
+ */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail("Conflict details logged to internal table with OID %u.",
+ MySubscription->conflictlogrelid));
+ }
}
/*
@@ -162,6 +256,143 @@ InitConflictIndexes(ResultRelInfo *relInfo)
relInfo->ri_onConflictArbiterIndexes = uniqueIndexes;
}
+/*
+ * GetConflictLogTableInfo
+ *
+ * Fetches conflict logging metadata from the cached MySubscription pointer.
+ * Sets the destination enum in *log_dest and, if applicable, opens and
+ * returns the relation handle for the internal log table.
+ */
+Relation
+GetConflictLogTableInfo(ConflictLogDest *log_dest)
+{
+ Oid conflictlogrelid;
+ Relation conflictlogrel = NULL;
+
+ /*
+ * Convert the text log destination to the internal enum. MySubscription
+ * already contains the data from pg_subscription.
+ */
+ *log_dest = GetLogDestination(MySubscription->conflictlogdest);
+ conflictlogrelid = MySubscription->conflictlogrelid;
+
+ /* If destination is 'log' only, no table to open. */
+ if (*log_dest == CONFLICT_LOG_DEST_LOG)
+ return NULL;
+
+ Assert(OidIsValid(conflictlogrelid));
+
+ conflictlogrel = table_open(conflictlogrelid, RowExclusiveLock);
+
+ /* Conflict log table is dropped or not accessible. */
+ if (conflictlogrel == NULL)
+ ereport(WARNING,
+ (errcode(ERRCODE_UNDEFINED_TABLE),
+ errmsg("conflict log table with OID %u does not exist",
+ conflictlogrelid)));
+
+ return conflictlogrel;
+}
+
+/*
+ * InsertConflictLogTuple
+ *
+ * Insert conflict log tuple into the conflict log table. It uses
+ * HEAP_INSERT_NO_LOGICAL to explicitly block logical decoding of the tuple
+ * inserted into the conflict log table.
+ */
+void
+InsertConflictLogTuple(Relation conflictlogrel)
+{
+ int options = HEAP_INSERT_NO_LOGICAL;
+
+ /* A valid tuple must be prepared and stored in MyLogicalRepWorker. */
+ Assert(MyLogicalRepWorker->conflict_log_tuple != NULL);
+
+ heap_insert(conflictlogrel, MyLogicalRepWorker->conflict_log_tuple,
+ GetCurrentCommandId(true), options, NULL);
+
+ /* Free conflict log tuple. */
+ heap_freetuple(MyLogicalRepWorker->conflict_log_tuple);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+}
+
+/*
+ * ValidateConflictLogTable - Validate conflict log table schema
+ *
+ * Checks whether the table definition including its column names, data
+ * types, and column ordering meet the requirements for conflict log
+ * table.
+ */
+bool
+ValidateConflictLogTable(Relation rel)
+{
+ Relation pg_attribute;
+ HeapTuple atup;
+ ScanKeyData scankey;
+ SysScanDesc scan;
+ int attcnt = 0;
+ bool tbl_ok = true;
+
+ pg_attribute = table_open(AttributeRelationId, AccessShareLock);
+ ScanKeyInit(&scankey,
+ Anum_pg_attribute_attrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationGetRelid(rel)));
+
+ scan = systable_beginscan(pg_attribute, AttributeRelidNumIndexId, true,
+ SnapshotSelf, 1, &scankey);
+
+ /* We only need to check up to MAX_CONFLICT_ATTR_NUM attributes */
+ while (HeapTupleIsValid(atup = systable_getnext(scan)))
+ {
+ const ConflictLogColumnDef *expected;
+ int schema_idx;
+ Form_pg_attribute attForm = (Form_pg_attribute) GETSTRUCT(atup);
+
+ /* Skip system columns and dropped columns */
+ if (attForm->attnum < 1 || attForm->attisdropped)
+ continue;
+
+ attcnt++;
+
+ /* attnum 1 corresponds to index 0 in ConflictLogSchema */
+ schema_idx = attForm->attnum - 1;
+
+ /* Check against the central schema definition */
+ if (schema_idx >= MAX_CONFLICT_ATTR_NUM)
+ {
+ /* Found an extra column beyond the required set */
+ tbl_ok = false;
+ break;
+ }
+
+ expected = &ConflictLogSchema[schema_idx];
+
+ if (attForm->atttypid != expected->atttypid ||
+ strcmp(NameStr(attForm->attname), expected->attname) != 0)
+ {
+ tbl_ok = false;
+ break;
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_attribute, AccessShareLock);
+
+ if (attcnt != MAX_CONFLICT_ATTR_NUM || !tbl_ok)
+ {
+ ereport(WARNING,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("conflict log table \"%s.%s\" structure changed, skipping insertion",
+ get_namespace_name(RelationGetNamespace(rel)),
+ RelationGetRelationName(rel)));
+ return false;
+ }
+
+ return true;
+}
+
/*
* Add SQLSTATE error code to the current conflict report.
*/
@@ -472,6 +703,40 @@ build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
return tuple_value.data;
}
+/*
+ * Helper function to extract the "raw" index key Datums and their null flags
+ * from a TupleTableSlot, given an already open index descriptor.
+ * This is the reusable core logic.
+ */
+static void
+build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull)
+{
+ TupleTableSlot *tableslot = slot;
+
+ /*
+ * If the slot is a virtual slot, copy it into a heap tuple slot as
+ * FormIndexDatum only works with heap tuple slots.
+ */
+ if (TTS_IS_VIRTUAL(slot))
+ {
+ /* Slot is created within the EState's tuple table */
+ tableslot = table_slot_create(localrel, &estate->es_tupleTable);
+ tableslot = ExecCopySlot(tableslot, slot);
+ }
+
+ /*
+ * Initialize ecxt_scantuple for potential use in FormIndexDatum
+ */
+ GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+
+ /* Form the index datums */
+ FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values,
+ isnull);
+}
+
/*
* Helper functions to construct a string describing the contents of an index
* entry. See BuildIndexValueDescription for details.
@@ -487,41 +752,319 @@ build_index_value_desc(EState *estate, Relation localrel, TupleTableSlot *slot,
Relation indexDesc;
Datum values[INDEX_MAX_KEYS];
bool isnull[INDEX_MAX_KEYS];
- TupleTableSlot *tableslot = slot;
- if (!tableslot)
+ if (!slot)
return NULL;
Assert(CheckRelationOidLockedByMe(indexoid, RowExclusiveLock, true));
indexDesc = index_open(indexoid, NoLock);
- /*
- * If the slot is a virtual slot, copy it into a heap tuple slot as
- * FormIndexDatum only works with heap tuple slots.
- */
- if (TTS_IS_VIRTUAL(slot))
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+
+ index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+
+ index_close(indexDesc, NoLock);
+
+ return index_value;
+}
+
+/*
+ * tuple_table_slot_to_json_datum
+ *
+ * Helper function to convert a TupleTableSlot to Jsonb.
+ */
+static Datum
+tuple_table_slot_to_json_datum(TupleTableSlot *slot)
+{
+ HeapTuple tuple;
+ Datum datum;
+ Datum json;
+
+ Assert(slot != NULL);
+
+ tuple = ExecCopySlotHeapTuple(slot);
+ datum = heap_copy_tuple_as_datum(tuple, slot->tts_tupleDescriptor);
+
+ json = DirectFunctionCall1(row_to_json, datum);
+ heap_freetuple(tuple);
+
+ return json;
+}
+
+/*
+ * tuple_table_slot_to_indextup_json
+ *
+ * Fetch replica identity key from the tuple table slot and convert into a
+ * jsonb datum.
+ */
+static Datum
+tuple_table_slot_to_indextup_json(EState *estate, Relation localrel,
+ Oid indexid, TupleTableSlot *slot)
+{
+ Relation indexDesc;
+ Datum values[INDEX_MAX_KEYS];
+ bool isnull[INDEX_MAX_KEYS];
+ HeapTuple tuple;
+ TupleDesc tupdesc;
+ Datum datum;
+
+ Assert(slot != NULL);
+
+ Assert(CheckRelationOidLockedByMe(indexid, RowExclusiveLock, true));
+
+ indexDesc = index_open(indexid, NoLock);
+
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+ tupdesc = RelationGetDescr(indexDesc);
+
+ /* Bless the tupdesc so it can be looked up by row_to_json. */
+ BlessTupleDesc(tupdesc);
+
+ /* Form the replica identity tuple. */
+ tuple = heap_form_tuple(tupdesc, values, isnull);
+ datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ index_close(indexDesc, NoLock);
+
+ /* Convert to a JSONB datum. */
+ return DirectFunctionCall1(row_to_json, datum);
+}
+
+static TupleDesc
+build_conflict_tupledesc(void)
+{
+ TupleDesc tupdesc;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ for (int i = 0; i < MAX_LOCAL_CONFLICT_INFO_ATTRS; i++)
+ TupleDescInitEntry(tupdesc, (AttrNumber) (i + 1),
+ LocalConflictSchema[i].attname,
+ LocalConflictSchema[i].atttypid,
+ -1, 0);
+
+ BlessTupleDesc(tupdesc);
+
+ return tupdesc;
+}
+
+/*
+ * Builds the local conflicts JSONB array column from the list of
+ * ConflictTupleInfo objects.
+ *
+ * Example output structure:
+ * [ { "xid": "1001", "commit_ts": "...", "origin": "...", "tuple": {...} }, ... ]
+ */
+static Datum
+build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples)
+{
+ ListCell *lc;
+ List *json_datums = NIL; /* List to hold the row_to_json results
+ * (type json) */
+ Datum *json_datum_array;
+ bool *json_null_array;
+ Datum json_array_datum;
+ int num_conflicts;
+ int i;
+ int16 typlen;
+ bool typbyval;
+ char typalign;
+ TupleDesc tupdesc;
+
+ /* Build local conflicts tuple descriptor. */
+ tupdesc = build_conflict_tupledesc();
+
+ /* Process local conflict tuple list and prepare an array of JSON. */
+ foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
{
- tableslot = table_slot_create(localrel, &estate->es_tupleTable);
- tableslot = ExecCopySlot(tableslot, slot);
+ Datum values[MAX_LOCAL_CONFLICT_INFO_ATTRS] = {0};
+ bool nulls[MAX_LOCAL_CONFLICT_INFO_ATTRS] = {0};
+ char *origin_name = NULL;
+ HeapTuple tuple;
+ Datum json_datum;
+ int attno;
+
+ attno = 0;
+ values[attno++] = TransactionIdGetDatum(conflicttuple->xmin);
+
+ if (conflicttuple->ts)
+ values[attno++] = TimestampTzGetDatum(conflicttuple->ts);
+ else
+ nulls[attno++] = true;
+
+ if (conflicttuple->origin != InvalidRepOriginId)
+ replorigin_by_oid(conflicttuple->origin, true, &origin_name);
+
+ /* Store empty string if origin name for the tuple is NULL. */
+ if (origin_name != NULL)
+ values[attno++] = CStringGetTextDatum(origin_name);
+ else
+ nulls[attno++] = true;
+
+ /*
+ * Add the conflicting key values in the case of a unique constraint
+ * violation.
+ */
+ if (conflict_type == CT_INSERT_EXISTS ||
+ conflict_type == CT_UPDATE_EXISTS ||
+ conflict_type == CT_MULTIPLE_UNIQUE_CONFLICTS)
+ {
+ Oid indexoid = conflicttuple->indexoid;
+
+ Assert(OidIsValid(indexoid) && conflicttuple->slot &&
+ CheckRelationOidLockedByMe(indexoid, RowExclusiveLock,
+ true));
+ values[attno++] =
+ tuple_table_slot_to_indextup_json(estate, rel,
+ indexoid,
+ conflicttuple->slot);
+ }
+ else
+ nulls[attno++] = true;
+
+ /* Convert conflicting tuple to JSON datum. */
+ if (conflicttuple->slot)
+ values[attno] = tuple_table_slot_to_json_datum(conflicttuple->slot);
+ else
+ nulls[attno] = true;
+
+ Assert(attno + 1 == MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ tuple = heap_form_tuple(tupdesc, values, nulls);
+
+ json_datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ /*
+ * Build the higher level JSON datum in format described in function
+ * header.
+ */
+ json_datum = DirectFunctionCall1(row_to_json, json_datum);
+
+ /* Done with the temporary tuple. */
+ heap_freetuple(tuple);
+
+ /* Add to the array element. */
+ json_datums = lappend(json_datums, (void *) json_datum);
}
- /*
- * Initialize ecxt_scantuple for potential use in FormIndexDatum when
- * index expressions are present.
- */
- GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+ num_conflicts = list_length(json_datums);
- /*
- * The values/nulls arrays passed to BuildIndexValueDescription should be
- * the results of FormIndexDatum, which are the "raw" input to the index
- * AM.
- */
- FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values, isnull);
+ json_datum_array = (Datum *) palloc(num_conflicts * sizeof(Datum));
+ json_null_array = (bool *) palloc0(num_conflicts * sizeof(bool));
- index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+ i = 0;
+ foreach(lc, json_datums)
+ {
+ json_datum_array[i] = (Datum) lfirst(lc);
+ i++;
+ }
- index_close(indexDesc, NoLock);
+ /* Construct the json[] array Datum. */
+ get_typlenbyvalalign(JSONOID, &typlen, &typbyval, &typalign);
+ json_array_datum = PointerGetDatum(construct_array(json_datum_array,
+ num_conflicts,
+ JSONOID,
+ typlen,
+ typbyval,
+ typalign));
+ pfree(json_datum_array);
+ pfree(json_null_array);
+
+ return json_array_datum;
+}
- return index_value;
+/*
+ * prepare_conflict_log_tuple
+ *
+ * This routine prepares a tuple detailing a conflict encountered during
+ * logical replication. The prepared tuple will be stored in
+ * MyLogicalRepWorker->conflict_log_tuple which should be inserted into the
+ * conflict log table by calling InsertConflictLogTuple.
+ */
+static void
+prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot)
+{
+ Datum values[MAX_CONFLICT_ATTR_NUM] = {0};
+ bool nulls[MAX_CONFLICT_ATTR_NUM] = {0};
+ int attno;
+ char *remote_origin = NULL;
+ MemoryContext oldctx;
+
+ Assert(MyLogicalRepWorker->conflict_log_tuple == NULL);
+
+ /* Populate the values and nulls arrays. */
+ attno = 0;
+ values[attno++] = ObjectIdGetDatum(RelationGetRelid(rel));
+
+ values[attno++] =
+ CStringGetTextDatum(get_namespace_name(RelationGetNamespace(rel)));
+
+ values[attno++] = CStringGetTextDatum(RelationGetRelationName(rel));
+
+ values[attno++] = CStringGetTextDatum(ConflictTypeNames[conflict_type]);
+
+ if (TransactionIdIsValid(remote_xid))
+ values[attno++] = TransactionIdGetDatum(remote_xid);
+ else
+ nulls[attno++] = true;
+
+ values[attno++] = LSNGetDatum(remote_final_lsn);
+
+ if (remote_commit_ts > 0)
+ values[attno++] = TimestampTzGetDatum(remote_commit_ts);
+ else
+ nulls[attno++] = true;
+
+ if (replorigin_session_origin != InvalidRepOriginId)
+ replorigin_by_oid(replorigin_session_origin, true, &remote_origin);
+
+ if (remote_origin != NULL)
+ values[attno++] = CStringGetTextDatum(remote_origin);
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(searchslot))
+ {
+ Oid replica_index = GetRelationIdentityOrPK(rel);
+
+ /*
+ * If the table has a valid replica identity index, build the index
+ * json datum from key value. Otherwise, construct it from the
+ * complete tuple in REPLICA IDENTITY FULL cases.
+ */
+ if (OidIsValid(replica_index))
+ values[attno++] = tuple_table_slot_to_indextup_json(estate, rel,
+ replica_index,
+ searchslot);
+ else
+ values[attno++] = tuple_table_slot_to_json_datum(searchslot);
+ }
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(remoteslot))
+ values[attno++] = tuple_table_slot_to_json_datum(remoteslot);
+ else
+ nulls[attno++] = true;
+
+ values[attno] = build_local_conflicts_json_array(estate, rel,
+ conflict_type,
+ conflicttuples);
+
+ Assert(attno + 1 == MAX_CONFLICT_ATTR_NUM);
+
+ oldctx = MemoryContextSwitchTo(ApplyContext);
+ MyLogicalRepWorker->conflict_log_tuple =
+ heap_form_tuple(RelationGetDescr(conflictlogrel), values, nulls);
+ MemoryContextSwitchTo(oldctx);
}
diff --git a/src/backend/replication/logical/launcher.c b/src/backend/replication/logical/launcher.c
index 3991e1495d4..bc7e1d9ebde 100644
--- a/src/backend/replication/logical/launcher.c
+++ b/src/backend/replication/logical/launcher.c
@@ -477,6 +477,7 @@ retry:
worker->oldest_nonremovable_xid = retain_dead_tuples
? MyReplicationSlot->data.xmin
: InvalidTransactionId;
+ worker->conflict_log_tuple = NULL;
worker->last_lsn = InvalidXLogRecPtr;
TIMESTAMP_NOBEGIN(worker->last_send_time);
TIMESTAMP_NOBEGIN(worker->last_recv_time);
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 718408bb599..cf1008c93dc 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -482,7 +482,9 @@ static bool MySubscriptionValid = false;
static List *on_commit_wakeup_workers_subids = NIL;
bool in_remote_transaction = false;
-static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+TransactionId remote_xid = InvalidTransactionId;
+TimestampTz remote_commit_ts = 0;
/* fields valid only when processing streamed transaction */
static bool in_streamed_transaction = false;
@@ -1219,6 +1221,8 @@ apply_handle_begin(StringInfo s)
set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
remote_final_lsn = begin_data.final_lsn;
+ remote_commit_ts = begin_data.committime;
+ remote_xid = begin_data.xid;
maybe_start_skipping_changes(begin_data.final_lsn);
@@ -1745,6 +1749,10 @@ apply_handle_stream_start(StringInfo s)
/* extract XID of the top-level transaction */
stream_xid = logicalrep_read_stream_start(s, &first_segment);
+ remote_xid = stream_xid;
+ remote_final_lsn = InvalidXLogRecPtr;
+ remote_commit_ts = 0;
+
if (!TransactionIdIsValid(stream_xid))
ereport(ERROR,
(errcode(ERRCODE_PROTOCOL_VIOLATION),
@@ -5609,6 +5617,28 @@ start_apply(XLogRecPtr origin_startpos)
pgstat_report_subscription_error(MySubscription->oid,
MyLogicalRepWorker->type);
+ /*
+ * Insert any pending conflict log tuple under a new transaction.
+ */
+ if (MyLogicalRepWorker->conflict_log_tuple != NULL)
+ {
+ Relation conflictlogrel;
+ ConflictLogDest dest;
+
+ StartTransactionCommand();
+ PushActiveSnapshot(GetTransactionSnapshot());
+
+ /* Open conflict log table and insert the tuple. */
+ conflictlogrel = GetConflictLogTableInfo(&dest);
+ if (ValidateConflictLogTable(conflictlogrel))
+ InsertConflictLogTuple(conflictlogrel);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+ table_close(conflictlogrel, RowExclusiveLock);
+
+ PopActiveSnapshot();
+ CommitTransactionCommand();
+ }
+
PG_RE_THROW();
}
}
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index e722df2d015..694e0ba26ee 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -132,7 +132,6 @@ static const ConflictLogColumnDef ConflictLogSchema[] =
{.attname = "local_conflicts",.atttypid = JSONARRAYOID}
};
-/* Define the count using the array size */
#define MAX_CONFLICT_ATTR_NUM (sizeof(ConflictLogSchema) / sizeof(ConflictLogSchema[0]))
extern bool GetTupleTransactionInfo(TupleTableSlot *localslot,
@@ -145,4 +144,7 @@ extern void ReportApplyConflict(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *remoteslot,
List *conflicttuples);
extern void InitConflictIndexes(ResultRelInfo *relInfo);
+extern Relation GetConflictLogTableInfo(ConflictLogDest *log_dest);
+extern void InsertConflictLogTuple(Relation conflictlogrel);
+extern bool ValidateConflictLogTable(Relation rel);
#endif
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index f081619f151..8ff556cc558 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -101,6 +101,9 @@ typedef struct LogicalRepWorker
*/
TransactionId oldest_nonremovable_xid;
+ /* A conflict log tuple that is prepared but not yet inserted. */
+ HeapTuple conflict_log_tuple;
+
/* Stats. */
XLogRecPtr last_lsn;
TimestampTz last_send_time;
@@ -256,6 +259,10 @@ extern PGDLLIMPORT bool InitializingApplyWorker;
extern PGDLLIMPORT List *table_states_not_ready;
+extern XLogRecPtr remote_final_lsn;
+extern TimestampTz remote_commit_ts;
+extern TransactionId remote_xid;
+
extern void logicalrep_worker_attach(int slot);
extern LogicalRepWorker *logicalrep_worker_find(LogicalRepWorkerType wtype,
Oid subid, Oid relid,
diff --git a/src/test/subscription/t/037_conflict_dest.pl b/src/test/subscription/t/037_conflict_dest.pl
new file mode 100644
index 00000000000..ceccd74b34a
--- /dev/null
+++ b/src/test/subscription/t/037_conflict_dest.pl
@@ -0,0 +1,190 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+# Test conflicts in logical replication
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+###############################
+# Setup
+###############################
+
+# Create a publisher node
+my $node_publisher = PostgreSQL::Test::Cluster->new('publisher');
+$node_publisher->init(allows_streaming => 'logical');
+$node_publisher->start;
+
+# Create a subscriber node
+my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
+$node_subscriber->init(allows_streaming => 'logical');
+$node_subscriber->start;
+
+# Create a table on publisher
+$node_publisher->safe_psql('postgres',
+ "CREATE TABLE conf_tab (a int PRIMARY KEY, b int UNIQUE, c int UNIQUE);");
+
+$node_publisher->safe_psql('postgres',
+ "CREATE TABLE conf_tab_2 (a int PRIMARY KEY, b int UNIQUE, c int UNIQUE);"
+);
+
+# Create same table on subscriber
+$node_subscriber->safe_psql('postgres',
+ "CREATE TABLE conf_tab (a int PRIMARY key, b int UNIQUE, c int UNIQUE);");
+
+$node_subscriber->safe_psql(
+ 'postgres', qq[
+ CREATE TABLE conf_tab_2 (a int PRIMARY KEY, b int, c int, unique(a,b)) PARTITION BY RANGE (a);
+ CREATE TABLE conf_tab_2_p1 PARTITION OF conf_tab_2 FOR VALUES FROM (MINVALUE) TO (100);
+]);
+
+# Setup logical replication
+my $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
+$node_publisher->safe_psql('postgres',
+ "CREATE PUBLICATION pub_tab FOR TABLE conf_tab, conf_tab_2");
+
+# Create the subscription
+my $appname = 'sub_tab';
+$node_subscriber->safe_psql(
+ 'postgres',
+ "CREATE SUBSCRIPTION sub_tab
+ CONNECTION '$publisher_connstr application_name=$appname'
+ PUBLICATION pub_tab WITH (conflict_log_destination=table)");
+
+# Wait for initial table sync to finish
+$node_subscriber->wait_for_subscription_sync($node_publisher, $appname);
+
+##################################################
+# INSERT data on Pub and Sub
+##################################################
+
+# Insert data in the publisher table
+$node_publisher->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (1,1,1);");
+
+# Insert data in the subscriber table
+$node_subscriber->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (2,2,2), (3,3,3), (4,4,4);");
+
+###############################################################################
+# Test conflict insertion into the internal conflict log table
+###############################################################################
+
+$node_subscriber->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (10, 10, 10);");
+
+# Get the internally generated table name
+my $subid = $node_subscriber->safe_psql('postgres',
+ "SELECT oid FROM pg_subscription WHERE subname = 'sub_tab';");
+my $conflict_table = "pg_conflict.conflict_log_table_$subid";
+
+$node_publisher->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (10, 20, 30);");
+
+# Wait for the conflict to be logged
+my $log_check = $node_subscriber->poll_query_until('postgres',
+ "SELECT count(*) > 0 FROM $conflict_table;");
+
+is($log_check, 1, 'Conflict was successfully logged to the internal table');
+
+my $json_query = qq[
+ SELECT string_agg((unnested.j::json)->'key'->>'a', ',')
+ FROM (
+ SELECT unnest(local_conflicts) AS j
+ FROM $conflict_table
+ ) AS unnested;
+];
+
+my $all_keys = $node_subscriber->safe_psql('postgres', $json_query);
+
+# Verify that '10' is present in the resulting string
+like($all_keys, qr/10/,
+ 'Verified that key 10 exists in the local_conflicts log');
+
+pass('Conflict type and data successfully validated in internal table');
+
+# Final cleanup for subsequent bidirectional tests in the script
+$node_subscriber->safe_psql('postgres', "TRUNCATE conf_tab;");
+
+###############################################################################
+# Test Case: update_missing
+###############################################################################
+
+# Sync a row, then delete it locally on subscriber
+$node_publisher->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (50, 50, 50);");
+$node_publisher->wait_for_catchup($appname);
+$node_subscriber->safe_psql('postgres', "DELETE FROM conf_tab WHERE a = 50;");
+
+# Trigger conflict by updating that row on publisher
+$node_publisher->safe_psql('postgres',
+ "UPDATE conf_tab SET b = 500 WHERE a = 50;");
+
+# Wait for the apply worker to detect the missing row and log it
+$node_subscriber->poll_query_until('postgres',
+ "SELECT count(*) > 0 FROM $conflict_table WHERE conflict_type = 'update_missing';"
+) or die "Timed out waiting for update_missing conflict";
+
+my $upd_miss_check = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM $conflict_table WHERE conflict_type = 'update_missing';"
+);
+is($upd_miss_check, 1,
+ 'Verified update_missing conflict logged to internal table');
+
+$node_subscriber->safe_psql('postgres', "TRUNCATE conf_tab;");
+
+###############################################################################
+# Test Case: insert_exists (via secondary unique index)
+###############################################################################
+
+# 1. Subscriber has a row with b=100
+$node_subscriber->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (100, 100, 100);");
+
+# 2. Publisher inserts a NEW PK (101) but a DUPLICATE 'b' (100)
+$node_publisher->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (101, 100, 101);");
+
+# 3. Verify it appears as 'insert_exists' in your log table
+$node_subscriber->poll_query_until('postgres',
+ "SELECT count(*) > 0 FROM $conflict_table WHERE conflict_type = 'insert_exists' AND local_conflicts::text LIKE '%100%';"
+) or die "Timed out waiting for secondary index insert_exists conflict";
+
+pass('Logged insert_exists triggered by secondary unique index violation');
+
+$node_subscriber->safe_psql('postgres', "TRUNCATE conf_tab;");
+
+###############################################################################
+# CASE 3: Switching Destination to 'log' (Server Log Verification)
+###############################################################################
+
+# Switch destination
+$node_subscriber->safe_psql('postgres',
+ "ALTER SUBSCRIPTION sub_tab SET (conflict_log_destination = 'all');");
+
+$node_subscriber->safe_psql('postgres', "DELETE FROM $conflict_table;");
+# Trigger a conflict for server log (insert_exists)
+$node_subscriber->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (600, 600, 600);");
+$node_publisher->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (600, 700, 700);");
+
+# Wait for table log
+$node_subscriber->poll_query_until('postgres',
+ "SELECT count(*) > 0 FROM $conflict_table;")
+ or die "Timed out waiting for insert_exists conflict";
+
+# Check subscriber server log
+my $log_found = $node_subscriber->wait_for_log(
+ qr/conflict detected on relation "public.conf_tab": conflict=insert_exists/
+);
+ok($log_found, 'Conflict correctly directed to server stderr log');
+
+# Verify table count DID NOT increase for this conflict
+my $table_check = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM $conflict_table WHERE local_conflicts::text LIKE '%600%';"
+);
+is($table_check, 1, 'Table log was bypassed when destination set to log');
+
+done_testing();
--
2.43.0
[text/x-patch] v17-0006-Allow-combined-conflict_log_destination-settings.patch (58.4K, ../../CALDaNm1aivk9KgQ5daeF6YZzuE+0wWc2yb7wb6qikNyvfPN0Sg@mail.gmail.com/7-v17-0006-Allow-combined-conflict_log_destination-settings.patch)
download | inline diff:
From 12dea53d33cadd32550c93ba935307cde176a6fe Mon Sep 17 00:00:00 2001
From: Vignesh C <vignesh21@gmail.com>
Date: Fri, 26 Dec 2025 20:22:08 +0530
Subject: [PATCH v17 6/6] Allow combined conflict_log_destination settings
Extend conflict_log_destination handling to support combined destination
specifications. Previously, only log, table, or all were accepted. This change
allows combinations of them like log, table and all, log, table etc
---
src/backend/catalog/pg_subscription.c | 2 +-
src/backend/commands/subscriptioncmds.c | 90 +++++++++++++++-------
src/backend/replication/logical/conflict.c | 6 +-
src/bin/pg_dump/pg_dump.c | 44 +++++++----
src/bin/pg_dump/t/002_pg_dump.pl | 4 +-
src/include/catalog/pg_subscription.h | 4 +-
src/include/commands/subscriptioncmds.h | 5 +-
src/include/replication/conflict.h | 9 ---
src/test/regress/expected/subscription.out | 72 +++++++++--------
src/test/regress/sql/subscription.sql | 11 ++-
10 files changed, 157 insertions(+), 90 deletions(-)
diff --git a/src/backend/catalog/pg_subscription.c b/src/backend/catalog/pg_subscription.c
index 5a0e5db1c03..c33b8de5943 100644
--- a/src/backend/catalog/pg_subscription.c
+++ b/src/backend/catalog/pg_subscription.c
@@ -147,7 +147,7 @@ GetSubscription(Oid subid, bool missing_ok)
datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
tup,
Anum_pg_subscription_subconflictlogdest);
- sub->conflictlogdest = TextDatumGetCString(datum);
+ sub->conflictlogdest = textarray_to_stringlist(DatumGetArrayTypeP(datum));
/* Is the subscription owner a superuser? */
sub->ownersuperuser = superuser_arg(sub->owner);
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index fb48d64801b..502d0814faf 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -60,6 +60,7 @@
#include "utils/pg_lsn.h"
#include "utils/regproc.h"
#include "utils/syscache.h"
+#include "utils/varlena.h"
/*
* Options that can be specified by the user in CREATE/ALTER SUBSCRIPTION
@@ -85,9 +86,6 @@
#define SUBOPT_ORIGIN 0x00020000
#define SUBOPT_CONFLICT_LOG_DEST 0x00040000
-/* check if the 'val' has 'bits' set */
-#define IsSet(val, bits) (((val) & (bits)) == (bits))
-
/*
* Structure to hold a bitmap representing the user-provided CREATE/ALTER
* SUBSCRIPTION command options and the parsed/default values of each of them.
@@ -418,14 +416,20 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
strcmp(defel->defname, "conflict_log_destination") == 0)
{
char *val;
- ConflictLogDest dest;
+ List *dest;
if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_LOG_DEST))
errorConflictingDefElem(defel, pstate);
val = defGetString(defel);
- dest = GetLogDestination(val);
- opts->logdest = dest;
+ if (!SplitIdentifierString(val, ',', &dest))
+ ereport(ERROR,
+ errcode(ERRCODE_SYNTAX_ERROR),
+ errmsg("invalid list syntax in parameter \"%s\"",
+ "conflict_log_destination"));
+
+ opts->logdest = GetLogDestination(dest, false);
+
opts->specified_opts |= SUBOPT_CONFLICT_LOG_DEST;
}
else
@@ -605,6 +609,30 @@ publicationListToArray(List *publist)
return PointerGetDatum(arr);
}
+/*
+ * Build a text[] array representing the conflict_log_destination flags.
+ */
+static Datum
+ConflictLogDestFlagsToArray(ConflictLogDest logdest)
+{
+ Datum datums[3];
+ int ndatums = 0;
+
+ if (IsSet(logdest, CONFLICT_LOG_DEST_ALL))
+ datums[ndatums++] = CStringGetTextDatum("all");
+ else
+ {
+ if (IsSet(logdest, CONFLICT_LOG_DEST_LOG))
+ datums[ndatums++] = CStringGetTextDatum("log");
+
+ if (IsSet(logdest, CONFLICT_LOG_DEST_TABLE))
+ datums[ndatums++] = CStringGetTextDatum("table");
+ }
+
+ return PointerGetDatum(
+ construct_array_builtin(datums, ndatums, TEXTOID));
+}
+
/*
* Create new subscription.
*/
@@ -776,14 +804,13 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
/* Always set the destination, default will be 'log'. */
values[Anum_pg_subscription_subconflictlogdest - 1] =
- CStringGetTextDatum(ConflictLogDestNames[opts.logdest]);
+ ConflictLogDestFlagsToArray(opts.logdest);
/*
* If logging to a table is required, physically create the logging
* relation and store its OID in the catalog.
*/
- if (opts.logdest == CONFLICT_LOG_DEST_TABLE ||
- opts.logdest == CONFLICT_LOG_DEST_ALL)
+ if (IsSet(opts.logdest, CONFLICT_LOG_DEST_TABLE))
{
Oid logrelid;
@@ -1407,7 +1434,7 @@ AlterSubscriptionConflictLogDestination(Subscription *sub,
ConflictLogDest logdest,
Oid *conflicttablerelid)
{
- ConflictLogDest old_dest = GetLogDestination(sub->conflictlogdest);
+ ConflictLogDest old_dest = GetLogDestination(sub->conflictlogdest, true);
bool want_table;
bool has_oldtable;
bool update_relid = false;
@@ -1824,7 +1851,7 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_LOG_DEST))
{
ConflictLogDest old_dest =
- GetLogDestination(sub->conflictlogdest);
+ GetLogDestination(sub->conflictlogdest, true);
if (opts.logdest != old_dest)
{
@@ -1832,7 +1859,7 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
Oid relid = InvalidOid;
values[Anum_pg_subscription_subconflictlogdest - 1] =
- CStringGetTextDatum(ConflictLogDestNames[opts.logdest]);
+ ConflictLogDestFlagsToArray(opts.logdest);
replaces[Anum_pg_subscription_subconflictlogdest - 1] = true;
update_relid = AlterSubscriptionConflictLogDestination(sub, opts.logdest, &relid);
@@ -3488,27 +3515,38 @@ create_conflict_log_table(Oid subid, char *subname)
/*
* GetLogDestination
*
- * Convert string to enum by comparing against standardized labels.
+ * Convert log destination List of strings to enums.
*/
ConflictLogDest
-GetLogDestination(const char *dest)
+GetLogDestination(List *destlist, bool strnodelist)
{
- /* Empty string or NULL defaults to LOG. */
- if (dest == NULL || dest[0] == '\0' || pg_strcasecmp(dest, "log") == 0)
+ ConflictLogDest logdest = 0;
+ ListCell *cell;
+
+ if (destlist == NULL)
return CONFLICT_LOG_DEST_LOG;
- if (pg_strcasecmp(dest,
- ConflictLogDestNames[CONFLICT_LOG_DEST_TABLE]) == 0)
- return CONFLICT_LOG_DEST_TABLE;
+ foreach(cell, destlist)
+ {
+ char *name;
- if (pg_strcasecmp(dest, ConflictLogDestNames[CONFLICT_LOG_DEST_ALL]) == 0)
- return CONFLICT_LOG_DEST_ALL;
+ name = (strnodelist) ? strVal(lfirst(cell)) : (char *) lfirst(cell);
- /* Unrecognized string. */
- ereport(ERROR,
- (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
- errmsg("unrecognized conflict_log_destination value: \"%s\"", dest),
- errhint("Valid values are \"log\", \"table\", and \"all\".")));
+ if (pg_strcasecmp(name, "log") == 0)
+ logdest |= CONFLICT_LOG_DEST_LOG;
+ else if (pg_strcasecmp(name, "table") == 0)
+ logdest |= CONFLICT_LOG_DEST_TABLE;
+ else if (pg_strcasecmp(name, "all") == 0)
+ logdest |= CONFLICT_LOG_DEST_ALL;
+ else
+ ereport(ERROR,
+ errcode(ERRCODE_SYNTAX_ERROR),
+ errmsg("unrecognized value for subscription parameter \"%s\": \"%s\"",
+ "conflict_log_destination", name),
+ errhint("Valid values are \"log\", \"table\", and \"all\"."));
+ }
+
+ return logdest;
}
/*
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index d7fe6e40b2f..6bf9cdb5730 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -170,7 +170,7 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
/* Insert to table if destination is 'table' or 'all' */
if (conflictlogrel)
{
- Assert((dest & CONFLICT_LOG_DEST_TABLE) != 0);
+ Assert(IsSet(dest, CONFLICT_LOG_DEST_TABLE));
if (ValidateConflictLogTable(conflictlogrel))
{
@@ -197,7 +197,7 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
pgstat_report_subscription_conflict(MySubscription->oid, type);
/* Decide what detail to show in server logs. */
- if (dest == CONFLICT_LOG_DEST_LOG || dest == CONFLICT_LOG_DEST_ALL)
+ if (IsSet(dest, CONFLICT_LOG_DEST_LOG) || IsSet(dest, CONFLICT_LOG_DEST_ALL))
{
/* Standard reporting with full internal details. */
ereport(elevel,
@@ -273,7 +273,7 @@ GetConflictLogTableInfo(ConflictLogDest *log_dest)
* Convert the text log destination to the internal enum. MySubscription
* already contains the data from pg_subscription.
*/
- *log_dest = GetLogDestination(MySubscription->conflictlogdest);
+ *log_dest = GetLogDestination(MySubscription->conflictlogdest, true);
conflictlogrelid = MySubscription->conflictlogrelid;
/* If destination is 'log' only, no table to open. */
diff --git a/src/bin/pg_dump/pg_dump.c b/src/bin/pg_dump/pg_dump.c
index 3bd2eef66e6..1b7704b8f57 100644
--- a/src/bin/pg_dump/pg_dump.c
+++ b/src/bin/pg_dump/pg_dump.c
@@ -5522,10 +5522,10 @@ dumpSubscription(Archive *fout, const SubscriptionInfo *subinfo)
DumpOptions *dopt = fout->dopt;
PQExpBuffer delq;
PQExpBuffer query;
- PQExpBuffer publications;
+ PQExpBuffer namebuf;
char *qsubname;
- char **pubnames = NULL;
- int npubnames = 0;
+ char **names = NULL;
+ int nnames = 0;
int i;
/* Do nothing if not dumping schema */
@@ -5545,19 +5545,22 @@ dumpSubscription(Archive *fout, const SubscriptionInfo *subinfo)
appendStringLiteralAH(query, subinfo->subconninfo, fout);
/* Build list of quoted publications and append them to query. */
- if (!parsePGArray(subinfo->subpublications, &pubnames, &npubnames))
+ if (!parsePGArray(subinfo->subpublications, &names, &nnames))
pg_fatal("could not parse %s array", "subpublications");
- publications = createPQExpBuffer();
- for (i = 0; i < npubnames; i++)
+ namebuf = createPQExpBuffer();
+ for (i = 0; i < nnames; i++)
{
if (i > 0)
- appendPQExpBufferStr(publications, ", ");
+ appendPQExpBufferStr(namebuf, ", ");
- appendPQExpBufferStr(publications, fmtId(pubnames[i]));
+ appendPQExpBufferStr(namebuf, fmtId(names[i]));
}
- appendPQExpBuffer(query, " PUBLICATION %s WITH (connect = false, slot_name = ", publications->data);
+ appendPQExpBuffer(query, " PUBLICATION %s WITH (connect = false, slot_name = ", namebuf->data);
+ resetPQExpBuffer(namebuf);
+ free(names);
+
if (subinfo->subslotname)
appendStringLiteralAH(query, subinfo->subslotname, fout);
else
@@ -5602,10 +5605,25 @@ dumpSubscription(Archive *fout, const SubscriptionInfo *subinfo)
appendPQExpBufferStr(query, ");\n");
+ /*
+ * Build list of quoted conflict log destinations and append them to
+ * query.
+ */
+ if (!parsePGArray(subinfo->subconflictlogdest, &names, &nnames))
+ pg_fatal("could not parse %s array", "conflict_log_destination");
+
+ for (i = 0; i < nnames; i++)
+ {
+ if (i > 0)
+ appendPQExpBufferStr(namebuf, ", ");
+
+ appendPQExpBuffer(namebuf, "%s", names[i]);
+ }
+
appendPQExpBuffer(query,
- "\n\nALTER SUBSCRIPTION %s SET (conflict_log_destination = %s);\n",
+ "\n\nALTER SUBSCRIPTION %s SET (conflict_log_destination = '%s');\n",
qsubname,
- subinfo->subconflictlogdest);
+ namebuf->data);
/*
* In binary-upgrade mode, we allow the replication to continue after the
@@ -5663,8 +5681,8 @@ dumpSubscription(Archive *fout, const SubscriptionInfo *subinfo)
NULL, subinfo->rolname,
subinfo->dobj.catId, 0, subinfo->dobj.dumpId);
- destroyPQExpBuffer(publications);
- free(pubnames);
+ destroyPQExpBuffer(namebuf);
+ free(names);
destroyPQExpBuffer(delq);
destroyPQExpBuffer(query);
diff --git a/src/bin/pg_dump/t/002_pg_dump.pl b/src/bin/pg_dump/t/002_pg_dump.pl
index 1023bbf2d1d..7f841359d9f 100644
--- a/src/bin/pg_dump/t/002_pg_dump.pl
+++ b/src/bin/pg_dump/t/002_pg_dump.pl
@@ -3204,10 +3204,10 @@ my %tests = (
create_order => 50,
create_sql => 'CREATE SUBSCRIPTION sub3
CONNECTION \'dbname=doesnotexist\' PUBLICATION pub1
- WITH (connect = false, origin = any, streaming = on, conflict_log_destination= table);',
+ WITH (connect = false, origin = any, streaming = on, conflict_log_destination= \'log,table\');',
regexp => qr/^
\QCREATE SUBSCRIPTION sub3 CONNECTION 'dbname=doesnotexist' PUBLICATION pub1 WITH (connect = false, slot_name = 'sub3', streaming = on);\E\n\n\n
- \QALTER SUBSCRIPTION sub3 SET (conflict_log_destination = table);\E
+ \QALTER SUBSCRIPTION sub3 SET (conflict_log_destination = 'all');\E
/xm,
like => { %full_runs, section_post_data => 1, },
unlike => {
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index 3328ff919f6..3a19fd081a4 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -108,7 +108,7 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
* Strategy for logging replication conflicts: 'log' - server log only,
* 'table' - internal table only, 'all' - both log and table.
*/
- text subconflictlogdest;
+ text subconflictlogdest[1] BKI_FORCE_NULL;
/* Only publish data originating from the specified origin */
text suborigin BKI_DEFAULT(LOGICALREP_ORIGIN_ANY);
@@ -167,7 +167,7 @@ typedef struct Subscription
List *publications; /* List of publication names to subscribe to */
char *origin; /* Only publish data originating from the
* specified origin */
- char *conflictlogdest; /* Conflict log destination */
+ List *conflictlogdest; /* Conflict log destination */
} Subscription;
#ifdef EXPOSE_TO_CLIENT_CODE
diff --git a/src/include/commands/subscriptioncmds.h b/src/include/commands/subscriptioncmds.h
index fc403cc4c5f..4a07170c827 100644
--- a/src/include/commands/subscriptioncmds.h
+++ b/src/include/commands/subscriptioncmds.h
@@ -19,6 +19,9 @@
#include "parser/parse_node.h"
#include "replication/conflict.h"
+/* check if the 'val' has 'bits' set */
+#define IsSet(val, bits) (((val) & (bits)) == (bits))
+
extern ObjectAddress CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bool isTopLevel);
extern ObjectAddress AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt, bool isTopLevel);
@@ -37,7 +40,7 @@ extern void CheckSubDeadTupleRetention(bool check_guc, bool sub_disabled,
bool retention_active,
bool max_retention_set);
-extern ConflictLogDest GetLogDestination(const char *dest);
+extern ConflictLogDest GetLogDestination(List *destlist, bool strnodelist);
extern bool IsConflictLogTable(Oid relid);
#endif /* SUBSCRIPTIONCMDS_H */
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index 694e0ba26ee..5440e3b986f 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -100,15 +100,6 @@ typedef enum ConflictLogDest
CONFLICT_LOG_DEST_ALL = (CONFLICT_LOG_DEST_LOG | CONFLICT_LOG_DEST_TABLE)
} ConflictLogDest;
-/*
- * Array mapping for converting internal enum to string.
- */
-static const char *const ConflictLogDestNames[] = {
- [CONFLICT_LOG_DEST_LOG] = "log",
- [CONFLICT_LOG_DEST_TABLE] = "table",
- [CONFLICT_LOG_DEST_ALL] = "all"
-};
-
/* Structure to hold metadata for one column of the conflict log table */
typedef struct ConflictLogColumnDef
{
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index 934fad364b2..4ca1e7f5546 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -119,7 +119,7 @@ HINT: To initiate replication, you must manually create the replication slot, e
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub4 SET (origin = any);
@@ -127,7 +127,7 @@ ALTER SUBSCRIPTION regress_testsub4 SET (origin = any);
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
DROP SUBSCRIPTION regress_testsub3;
@@ -148,7 +148,7 @@ ERROR: invalid connection string syntax: missing "=" after "foobar" in connecti
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET PUBLICATION testpub2, testpub3 WITH (refresh = false);
@@ -160,7 +160,7 @@ ALTER SUBSCRIPTION regress_testsub SET (run_as_owner = true);
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (password_required = true);
@@ -179,7 +179,7 @@ ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/12345');
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345 | log
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345 | {log}
(1 row)
-- ok - with lsn = NONE
@@ -191,7 +191,7 @@ ERROR: invalid WAL location (LSN): 0/0
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | {log}
(1 row)
BEGIN;
@@ -226,7 +226,7 @@ HINT: Available values: local, remote_write, remote_apply, on, off.
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
---------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
- regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000 | log
+ regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000 | {log}
(1 row)
-- rename back to keep the rest simple
@@ -258,7 +258,7 @@ HINT: To initiate replication, you must manually create the replication slot, e
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (binary = false);
@@ -267,7 +267,7 @@ ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -282,7 +282,7 @@ HINT: To initiate replication, you must manually create the replication slot, e
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = parallel);
@@ -290,7 +290,7 @@ ALTER SUBSCRIPTION regress_testsub SET (streaming = parallel);
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = false);
@@ -299,7 +299,7 @@ ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
-- fail - publication already exists
@@ -317,7 +317,7 @@ ERROR: publication "testpub1" is already in subscription "regress_testsub"
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
-- fail - publication used more than once
@@ -335,7 +335,7 @@ ALTER SUBSCRIPTION regress_testsub DROP PUBLICATION testpub1, testpub2 WITH (ref
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -374,7 +374,7 @@ HINT: To initiate replication, you must manually create the replication slot, e
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
-- we can alter streaming when two_phase enabled
@@ -383,7 +383,7 @@ ALTER SUBSCRIPTION regress_testsub SET (streaming = true);
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -396,7 +396,7 @@ HINT: To initiate replication, you must manually create the replication slot, e
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -412,7 +412,7 @@ HINT: To initiate replication, you must manually create the replication slot, e
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (disable_on_error = true);
@@ -420,7 +420,7 @@ ALTER SUBSCRIPTION regress_testsub SET (disable_on_error = true);
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -436,7 +436,7 @@ HINT: To initiate replication, you must manually create the replication slot, e
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -453,7 +453,7 @@ HINT: To initiate replication, you must manually create the replication slot, e
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
-- ok
@@ -462,7 +462,7 @@ ALTER SUBSCRIPTION regress_testsub SET (max_retention_duration = 0);
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -523,7 +523,7 @@ DROP SUBSCRIPTION regress_testsub;
SET SESSION AUTHORIZATION 'regress_subscription_user';
-- fail - unrecognized parameter value
CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
-ERROR: unrecognized conflict_log_destination value: "invalid"
+ERROR: unrecognized value for subscription parameter "conflict_log_destination": "invalid"
HINT: Valid values are "log", "table", and "all".
-- verify subconflictlogdest is 'log' and relid is 0 (InvalidOid) for default case
CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
@@ -533,7 +533,7 @@ SELECT subname, subconflictlogdest, subconflictlogrelid
FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
subname | subconflictlogdest | subconflictlogrelid
------------------------------+--------------------+---------------------
- regress_conflict_log_default | log | 0
+ regress_conflict_log_default | {log} | 0
(1 row)
-- verify empty string defaults to 'log'
@@ -544,11 +544,11 @@ SELECT subname, subconflictlogdest, subconflictlogrelid
FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
subname | subconflictlogdest | subconflictlogrelid
----------------------------+--------------------+---------------------
- regress_conflict_empty_str | log | 0
+ regress_conflict_empty_str | {log} | 0
(1 row)
-- this should generate an internal table named conflict_log_table_$subid$
-CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log, table');
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
-- check metadata in pg_subscription: destination should be 'table' and relid valid
@@ -556,7 +556,7 @@ SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
FROM pg_subscription WHERE subname = 'regress_conflict_test1';
subname | subconflictlogdest | has_relid
------------------------+--------------------+-----------
- regress_conflict_test1 | table | t
+ regress_conflict_test1 | {all} | t
(1 row)
-- verify the physical table exists and its OID matches subconflictlogrelid
@@ -586,18 +586,28 @@ WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0;
-- These tests verify the transition logic between different logging
-- destinations, ensuring internal tables are created or dropped as expected.
--
--- transition from 'log' to 'all'
+-- transition from 'log' to 'log, table'
-- a new internal conflict log table should be created
CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log, table');
+-- verify metadata after ALTER (destination should be 'log, table')
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogdest | has_relid
+------------------------+--------------------+-----------
+ regress_conflict_test2 | {all} | t
+(1 row)
+
+-- transition from 'log, table' to 'all'
ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
-- verify metadata after ALTER (destination should be 'all')
SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
FROM pg_subscription WHERE subname = 'regress_conflict_test2';
subname | subconflictlogdest | has_relid
------------------------+--------------------+-----------
- regress_conflict_test2 | all | t
+ regress_conflict_test2 | {all} | t
(1 row)
-- transition from 'all' to 'table'
@@ -608,7 +618,7 @@ SELECT subconflictlogdest, subconflictlogrelid = :old_relid AS relid_unchanged
FROM pg_subscription WHERE subname = 'regress_conflict_test2';
subconflictlogdest | relid_unchanged
--------------------+-----------------
- table | t
+ {table} | t
(1 row)
-- transition from 'table' to 'log'
@@ -618,7 +628,7 @@ SELECT subconflictlogdest, subconflictlogrelid
FROM pg_subscription WHERE subname = 'regress_conflict_test2';
subconflictlogdest | subconflictlogrelid
--------------------+---------------------
- log | 0
+ {log} | 0
(1 row)
-- verify the physical table is gone
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index c88fb405711..2491dc16c2a 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -385,7 +385,7 @@ SELECT subname, subconflictlogdest, subconflictlogrelid
FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
-- this should generate an internal table named conflict_log_table_$subid$
-CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log, table');
-- check metadata in pg_subscription: destination should be 'table' and relid valid
SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
@@ -411,9 +411,16 @@ WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0;
-- destinations, ensuring internal tables are created or dropped as expected.
--
--- transition from 'log' to 'all'
+-- transition from 'log' to 'log, table'
-- a new internal conflict log table should be created
CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log, table');
+
+-- verify metadata after ALTER (destination should be 'log, table')
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'log, table' to 'all'
ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
-- verify metadata after ALTER (destination should be 'all')
--
2.43.0
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2025-12-29 06:02 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
5 siblings, 1 reply; 912+ messages in thread
From: shveta malik @ 2025-12-29 06:02 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; vignesh C <vignesh21@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Thu, Dec 25, 2025 at 1:10 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> Here is the patches I have changed by using IsSystemClass(), based on
> this many other things changed like we don't need to check for the
> temp schema and also the caller of create_conflict_log_table() now
> don't need to find the creation schema so it don't need to generate
> the relname so that part is also moved within
> create_conflict_log_table(). Fixed most of the comments given by
> Peter and Shveta, although some of them are still open e.g. the name
> of the conflict log table as of now I have kept as
> conflict_log_table_<subid> other options are
>
> 1. pg_conflict_<subid>
> 2. conflict_log_<subid>
> 3. sub_conflict_log_<subid>
>
> I prefer 3, considering it says this table holds subscription conflict
> logs. Thoughts?
>
I was checking how pg_toast does it. It creates tables with names:
"pg_toast_%u", relOid
We can do similar i.e., the schema name as pg_conflict and table name
as pg_conflict_<subid>. Thoughts?
Few comments on 001:
1)
It will be good to display conflict tablename in \dRs command
2)
postgres=# ALTER TABLE sch1.t3 set schema pg_toast;
ERROR: cannot move objects into or out of TOAST schema
But when we move to pg_conflict, it works. It should error out as well.
postgres=# ALTER TABLE sch1.t1 set schema pg_conflict;
ALTER TABLE
3)
Shall we LOG CLT creation and drop during create/alter sub?
4)
create_conflict_log_table()
+ /* Report an error if the specified conflict log table already exists. */
+ if (OidIsValid(get_relname_relid(relname, PG_CONFLICT_NAMESPACE)))
+ ereport(ERROR,
+ (errcode(ERRCODE_DUPLICATE_TABLE),
+ errmsg("relation \"%s.%s\" already exists",
+ get_namespace_name(PG_CONFLICT_NAMESPACE), relname)));
I am unable to think of a valid user-scenario when the above will be
hit. Do we need this as a user-error or simply an Assert or
internal-error will do?
5)
+ /*
+ * Establish an internal dependency between the conflict log table and the
+ * subscription. By using DEPENDENCY_INTERNAL, we ensure the table is
+ * automatically reaped when the subscription is dropped. This also
+ * prevents the table from being dropped independently unless the
+ * subscription itself is removed.
+ */
+ ObjectAddressSet(myself, RelationRelationId, relid);
+ ObjectAddressSet(subaddr, SubscriptionRelationId, subid);
+ recordDependencyOn(&myself, &subaddr, DEPENDENCY_INTERNAL);
Now that we have pg_conflict, which is treated similarly to a system
catalog, I’m wondering whether we actually need to maintain this
dependency to prevent the CLT table or schema from being dropped.
Also, given that this currently goes against the convention that a
shared object cannot be present in pg_depend, could DropSubscription()
and AlterSubscription() instead handle dropping the table explicitly
in required scenarios?
6)
+ descr => 'reserved schema for conflict tables',
Shall we say: 'reserved schema for subscription-specific conflict tables'
or anything better to include that it is subscription related?
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-01 05:56 vignesh C <vignesh21@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
5 siblings, 1 reply; 912+ messages in thread
From: vignesh C @ 2026-01-01 05:56 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, 25 Dec 2025 at 13:10, Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Wed, Dec 24, 2025 at 4:02 PM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > On Tue, Dec 23, 2025 at 5:52 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > On Tue, Dec 23, 2025 at 5:18 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > > >
> > > > On Tue, Dec 23, 2025 at 10:55 AM shveta malik <shveta.malik@gmail.com> wrote:
> > > > >
> > > > > On Mon, Dec 22, 2025 at 9:11 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > > > >
> > > > > > On Mon, Dec 22, 2025 at 3:09 PM shveta malik <shveta.malik@gmail.com> wrote:
> > > > > >
> > > > > > I think this needs more thought, others can be fixed.
> > > > > >
> > > > > > > 2)
> > > > > > > postgres=# drop schema shveta cascade;
> > > > > > > NOTICE: drop cascades to subscription sub1
> > > > > > > ERROR: global objects cannot be deleted by doDeletion
> > > > > > >
> > > > > > > Is this expected? Is the user supposed to see this error?
> > > > > > >
> > > > > > See below code, so this says if the object being dropped is the
> > > > > > outermost object (i.e. if we are dropping the table directly) then it
> > > > > > will disallow dropping the object on which it has INTERNAL DEPENDENCY,
> > > > > > OTOH if the object is being dropped via recursive drop (i.e. the table
> > > > > > is being dropped while dropping the schema) then object on which it
> > > > > > has INTERNAL dependency will also be added to the deletion list and
> > > > > > later will be dropped via doDeletion and later we are getting error as
> > > > > > subscription is a global object. I thought maybe we can handle an
> > > > > > additional case that the INTERNAL DEPENDENCY, is on subscription the
> > > > > > disallow dropping it irrespective of whether it is being called
> > > > > > directly or via recursive drop but then it will give an issue even
> > > > > > when we are trying to drop table during subscription drop, we can make
> > > > > > handle this case as well via 'flags' passed in findDependentObjects()
> > > > > > but need more investigation.
> > > > > >
> > > > > > Seeing this complexity makes me think more on is it really worth it to
> > > > > > maintain this dependency? Because during subscription drop we anyway
> > > > > > have to call performDeletion externally because this dependency is
> > > > > > local so we are just disallowing the conflict table drop, however the
> > > > > > ALTER table is allowed so what we are really protecting by protecting
> > > > > > the table drop, I think it can be just documented that if user try to
> > > > > > drop the table then conflict will not be inserted anymore?
> > > > > >
> > > > > > findDependentObjects()
> > > > > > {
> > > > > > ...
> > > > > > switch (foundDep->deptype)
> > > > > > {
> > > > > > ....
> > > > > > case DEPENDENCY_INTERNAL:
> > > > > > * 1. At the outermost recursion level, we must disallow the
> > > > > > * DROP. However, if the owning object is listed in
> > > > > > * pendingObjects, just release the caller's lock and return;
> > > > > > * we'll eventually complete the DROP when we reach that entry
> > > > > > * in the pending list.
> > > > > > }
> > > > > > }
> > > > > >
> > > > > > [1]
> > > > > > postgres[1333899]=# select * from pg_depend where objid > 16410;
> > > > > > classid | objid | objsubid | refclassid | refobjid | refobjsubid | deptype
> > > > > > ---------+-------+----------+------------+----------+-------------+---------
> > > > > > 1259 | 16420 | 0 | 2615 | 16410 | 0 | n
> > > > > > 1259 | 16420 | 0 | 6100 | 16419 | 0 | i
> > > > > > (4 rows)
> > > > > >
> > > > > > 16420 -> conflict_log_table_16419
> > > > > > 16419 -> subscription
> > > > > > 16410 -> schema s1
> > > > > >
> > > > >
> > > > > One approach could be to use something similar to
> > > > > PERFORM_DELETION_SKIP_EXTENSIONS in our case, but only for recursive
> > > > > drops. The effect would be that 'DROP SCHEMA ... CASCADE' would
> > > > > proceed without error, i.e., it would drop the tables as well without
> > > > > including the subscription in the dependency list. But if we try to
> > > > > drop a table directly (e.g., DROP TABLE CLT), it will still result in:
> > > > > ERROR: cannot drop table because subscription sub1 requires it
> > > > >
> > > >
> > > > I think this way of allowing dropping the conflict table without
> > > > caring for the parent object (subscription) is not a good idea. How
> > > > about creating a dedicated schema, say pg_conflict for the purpose of
> > > > storing conflict tables? This will be similar to the pg_toast schema
> > > > for toast tables. So, similar to that each database will have a
> > > > pg_conflict schema. It prevents the "orphan" problem where a user
> > > > accidentally drops the logging schema but the Subscription is still
> > > > trying to write to it. pg_dump needs to ignore all system schemas
> > > > EXCEPT pg_conflict. This ensures the history is preserved during
> > > > migrations while still protecting the tables from accidental user
> > > > deletion. About permissions, I think we need to set the schema
> > > > permissions so that USAGE is public (so users can SELECT from their
> > > > logs) but CREATE is restricted to the superuser/subscription owner. We
> > > > may need to think some more about permissions.
> > > >
> > > > I also tried to reason out if we can allow storing the conflict table
> > > > in pg_catalog but here are a few reasons why it won't be a good idea.
> > > > I think by default, pg_dump completely ignores the pg_catalog schema.
> > > > It assumes pg_catalog contains static system definitions (like
> > > > pg_class, pg_proc, etc.) that are re-generated by the initdb process,
> > > > not user data. If we place a conflict table in pg_catalog, it will not
> > > > be backed up. If a user runs pg_dump/all to migrate to a new server,
> > > > their subscription definition will survive, but their entire history
> > > > of conflict logs will vanish. Also from the permissions angle, If a
> > > > user wants to write a custom PL/pgSQL function to "retry" conflicts,
> > > > they might need to DELETE rows from the conflict table after fixing
> > > > them. Granting DELETE permissions on a table inside pg_catalog is
> > > > non-standard and often frowned upon by security auditors. It blurs the
> > > > line between "System Internals" (immutable) and "User Data" (mutable).
> > > > So, in short a separate pg_conflict schema appears to be a better solution.
> > >
> > > Yeah that makes sense. Although I haven't thought about all cases
> > > whether it can be a problem anywhere, but meanwhile I tried
> > > prototyping with this and it behaves what we want.
> > >
> > > postgres[1651968]=# select * from pg_conflict.conflict_log_table_16406 ;
> > > relid | schemaname | relname | conflict_type | remote_xid |
> > > remote_commit_lsn | remote_commit_ts | remote_origin |
> > > replica_identity | remote_tuple
> > > |
> > > local_conflicts
> > > -------+------------+---------+-----------------------+------------+-------------------+-------------------------------+---------------+------------------+----------------
> > > +------------------------------------------------------------------------------------------------------------------------------------
> > > 16385 | public | test | update_origin_differs | 761 |
> > > 0/01760BD8 | 2025-12-23 11:08:30.583816+00 | pg_16406 |
> > > {"a":1} | {"a":1,"b":20}
> > > | {"{\"xid\":\"772\",\"commit_ts\":\"2025-12-23T11:08:25.568561+00:00\",\"origin\":null,\"key\":null,\"tuple\":{\"a\":1,\"b\":10}}"}
> > > (1 row)
> > >
> > > -- Case1: Alter is not allowed
> > > postgres[1651968]=# ALTER TABLE pg_conflict.conflict_log_table_16406
> > > ADD COLUMN a int;
> > > ERROR: 42501: permission denied: "conflict_log_table_16406" is a system catalog
> > > LOCATION: RangeVarCallbackForAlterRelation, tablecmds.c:19634
> > >
> >
> > How was this achieved? Did you modify IsSystemClass to behave
> > similarly to IsToastClass?
>
> Right
>
> > I tried to analyze whether there are alternative approaches. The
> > possible options I see are:
> >
> > 1)
> > heap_create_with_catalog() provides the boolean argument use_user_acl,
> > which is meant to apply user-defined default privileges. In theory, we
> > could predefine default ACLs for our schema and then invoke
> > heap_create_with_catalog() with use_user_acl = true. But it’s not
> > clear how to do this purely from internal code. We would need to mimic
> > or reuse the logic behind SetDefaultACLsInSchemas.
> > 2)
> > Another option is to create the table using heap_create_with_catalog()
> > with use_user_acl = false, and then explicitly update pg_class.relacl
> > for that table, similar to what ExecGrant_Relation does when
> > processing GRANT/REVOKE. But I couldn’t find any existing internal
> > code paths (outside of the GRANT/REVOKE implementation itself) that do
> > this kind of post-creation ACL manipulation.
>
> I haven't analyzed this options, I will do that but not before Jan 3rd
> as I will be away from my laptop for a week.
>
> > So overall, I feel changing IsSystemClass is the simpler way right
> > now. To set ACL before/after/during heap_create_with_catalog is a
> > tricky thing, at-least I could not find an easier way to do this,
> > unless I have missed something.
> > Thoughts on possible approaches?
>
> Here is the patches I have changed by using IsSystemClass(), based on
> this many other things changed like we don't need to check for the
> temp schema
IsSystemClass(Oid relid, Form_pg_class reltuple)
{
/* IsCatalogRelationOid is a bit faster, so test that first */
- return (IsCatalogRelationOid(relid) || IsToastClass(reltuple));
+ return (IsCatalogRelationOid(relid) || IsToastClass(reltuple)
+ || IsConflictClass(reltuple));
}
After this change we will not be able to truncate the user created
tables in pg_conflict schema:
postgres=# create table pg_conflict.t1(c1 int);
CREATE TABLE
postgres=# insert into pg_conflict.t1 values(1);
INSERT 0 1
-- This error is thrown because IsConflictClass check is included in
IsSystemClass function:
postgres=# truncate pg_conflict.t1 ;
ERROR: permission denied: "t1" is a system catalog
I'm not sure if this is intentional. I felt either we should not allow
the create table or truncate table should be allowed.
Similarly others commands too:
postgres=# alter table pg_conflict.t1 set schema public;
ERROR: permission denied: "t1" is a system catalog
postgres=# alter table pg_conflict.t1 rename to t2;
ERROR: permission denied: "t1" is a system catalog
Regards,
Vignesh
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-01 07:02 shveta malik <shveta.malik@gmail.com>
parent: vignesh C <vignesh21@gmail.com>
0 siblings, 2 replies; 912+ messages in thread
From: shveta malik @ 2026-01-01 07:02 UTC (permalink / raw)
To: vignesh C <vignesh21@gmail.com>; +Cc: Dilip Kumar <dilipbalaut@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Thu, Jan 1, 2026 at 11:43 AM vignesh C <vignesh21@gmail.com> wrote:
>
>
> IsSystemClass(Oid relid, Form_pg_class reltuple)
> {
> /* IsCatalogRelationOid is a bit faster, so test that first */
> - return (IsCatalogRelationOid(relid) || IsToastClass(reltuple));
> + return (IsCatalogRelationOid(relid) || IsToastClass(reltuple)
> + || IsConflictClass(reltuple));
> }
>
>
> After this change we will not be able to truncate the user created
> tables in pg_conflict schema:
> postgres=# create table pg_conflict.t1(c1 int);
> CREATE TABLE
> postgres=# insert into pg_conflict.t1 values(1);
> INSERT 0 1
>
But do we even want to create user-tables (other than CLT) in
pg_conflict schema? I feel operations like creation of tables or
moving any table in and out of pg_conflict schema (as suggested in my
previous email) should not even be allowed, similar to pg_toast.
postgres=# create table pg_toast.t1(i int);
ERROR: permission denied to create "pg_toast.t1"
DETAIL: System catalog modifications are currently disallowed.
postgres=# ALTER TABLE sch1.t3 set schema pg_toast;
ERROR: cannot move objects into or out of TOAST schema
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-01 10:31 shveta malik <shveta.malik@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: shveta malik @ 2026-01-01 10:31 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; vignesh C <vignesh21@gmail.com>; shveta malik <shveta.malik@gmail.com>
Few comments on v17-003.
<The doc does not compile.>
logical-replication.sgml
1)
+ <link linkend="sql-dropsubscription"><command>DROP
SUBSCRIPTION</command></link>. When the
+ <literal>conflict_log_destination</literal> parameter is set to
<literal>table</literal>
+ or <literal>all</literal>, the system automatically manages a
dedicated conflict
+ storage table. This table is dropped automatically when the subscription is
+ removed via <command>DROP SUBSCRIPTION</command>.
+ <para>
+ Conflicts that occur during replication are typically logged as plain text
+ in the server log, which can be difficult for automated monitoring and
+ analysis. The <command>CREATE SUBSCRIPTION</command> command provides the
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination">
+ <literal>conflict_log_destination</literal></link> option to record detailed
+ conflict information in a structured, queryable format, significantly
+ improving post-mortem analysis and operational visibility of the replication
+ setup by logging to a system-managed table.
+
Do we really need these at 2 places in the same section? The 2nd
paragraph can be tweaked to include the first one and placed at the
end of that section. How about:
Conflicts that occur during replication are, by default, logged as plain text
in the server log, which can make automated monitoring and analysis difficult.
The <command>CREATE SUBSCRIPTION</command> command provides the
<link linkend="sql-createsubscription-params-with-conflict-log-destination">
<literal>conflict_log_destination</literal></link> option to record detailed
conflict information in a structured, queryable format. When this parameter
is set to <literal>table</literal> or <literal>all</literal>, the system
automatically manages a dedicated conflict storage table, which is created
and dropped along with the subscription. This significantly improves post-mortem
analysis and operational visibility of the replication setup.
2)
+ in the following <firstterm>conflict</firstterm> cases. If the subscription
+ was created with the <literal>conflict_log_destination</literal> set to
+ <literal>table</literal>, detailed conflict information is also inserted
+ into an internally managed table named
+ When the <literal>conflict_log_destination</literal> is set to
+ <literal>table</literal>, the system automatically creates a new table with
+ a predefined schema to log conflict details.
Should we mention 'all' also in both of above:
3)
+ <literal>pg_conflict.conflict_log_table_<subscription_oid></literal>,
I think we can not write <subscription_oid>, it will look for
finishing tag </sub..>.
4)
The log format for logical replication conflicts is as follows:
We can even modify this line to something like:
If <literal>conflict_log_destination</literal> is left at the default
setting or explicitly configured
as <literal>log</literal> or <literal>all</literal>, logical
replication conflicts are logged in the following format:
5)
alter_subscription.sgml:
+
+ <para>
+ When switching <literal>conflict_log_destination</literal> to
<literal>table</literal>,
+ the system will ensure the internal logging table exists. If
switching away
+ from <literal>table</literal>, the logging stops, but the
previously recorded
+ data remains until the subscription is dropped.
+ </para>
I do not think this info is true. We drop the table when we alter
conflict_log_destination to set a non-table value.
6)
In create_subscription.sgml where we talk about conflict log table,
shall we also point to its structure mentioned in the Conflict page?
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-01 11:04 vignesh C <vignesh21@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
1 sibling, 0 replies; 912+ messages in thread
From: vignesh C @ 2026-01-01 11:04 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Dilip Kumar <dilipbalaut@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, 1 Jan 2026 at 12:32, shveta malik <shveta.malik@gmail.com> wrote:
>
> On Thu, Jan 1, 2026 at 11:43 AM vignesh C <vignesh21@gmail.com> wrote:
> >
> >
> > IsSystemClass(Oid relid, Form_pg_class reltuple)
> > {
> > /* IsCatalogRelationOid is a bit faster, so test that first */
> > - return (IsCatalogRelationOid(relid) || IsToastClass(reltuple));
> > + return (IsCatalogRelationOid(relid) || IsToastClass(reltuple)
> > + || IsConflictClass(reltuple));
> > }
> >
> >
> > After this change we will not be able to truncate the user created
> > tables in pg_conflict schema:
> > postgres=# create table pg_conflict.t1(c1 int);
> > CREATE TABLE
> > postgres=# insert into pg_conflict.t1 values(1);
> > INSERT 0 1
> >
>
> But do we even want to create user-tables (other than CLT) in
> pg_conflict schema? I feel operations like creation of tables or
> moving any table in and out of pg_conflict schema (as suggested in my
> previous email) should not even be allowed, similar to pg_toast.
I also felt creation of tables should not be allowed, in case we plan
to allow creation, then the other operations also should be allowed.
Regards,
Vignesh
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-01 11:05 vignesh C <vignesh21@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
5 siblings, 0 replies; 912+ messages in thread
From: vignesh C @ 2026-01-01 11:05 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, 25 Dec 2025 at 13:10, Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Wed, Dec 24, 2025 at 4:02 PM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > On Tue, Dec 23, 2025 at 5:52 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > On Tue, Dec 23, 2025 at 5:18 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > > >
> > > > On Tue, Dec 23, 2025 at 10:55 AM shveta malik <shveta.malik@gmail.com> wrote:
> > > > >
> > > > > On Mon, Dec 22, 2025 at 9:11 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > > > >
> > > > > > On Mon, Dec 22, 2025 at 3:09 PM shveta malik <shveta.malik@gmail.com> wrote:
> > > > > >
> > > > > > I think this needs more thought, others can be fixed.
> > > > > >
> > > > > > > 2)
> > > > > > > postgres=# drop schema shveta cascade;
> > > > > > > NOTICE: drop cascades to subscription sub1
> > > > > > > ERROR: global objects cannot be deleted by doDeletion
> > > > > > >
> > > > > > > Is this expected? Is the user supposed to see this error?
> > > > > > >
> > > > > > See below code, so this says if the object being dropped is the
> > > > > > outermost object (i.e. if we are dropping the table directly) then it
> > > > > > will disallow dropping the object on which it has INTERNAL DEPENDENCY,
> > > > > > OTOH if the object is being dropped via recursive drop (i.e. the table
> > > > > > is being dropped while dropping the schema) then object on which it
> > > > > > has INTERNAL dependency will also be added to the deletion list and
> > > > > > later will be dropped via doDeletion and later we are getting error as
> > > > > > subscription is a global object. I thought maybe we can handle an
> > > > > > additional case that the INTERNAL DEPENDENCY, is on subscription the
> > > > > > disallow dropping it irrespective of whether it is being called
> > > > > > directly or via recursive drop but then it will give an issue even
> > > > > > when we are trying to drop table during subscription drop, we can make
> > > > > > handle this case as well via 'flags' passed in findDependentObjects()
> > > > > > but need more investigation.
> > > > > >
> > > > > > Seeing this complexity makes me think more on is it really worth it to
> > > > > > maintain this dependency? Because during subscription drop we anyway
> > > > > > have to call performDeletion externally because this dependency is
> > > > > > local so we are just disallowing the conflict table drop, however the
> > > > > > ALTER table is allowed so what we are really protecting by protecting
> > > > > > the table drop, I think it can be just documented that if user try to
> > > > > > drop the table then conflict will not be inserted anymore?
> > > > > >
> > > > > > findDependentObjects()
> > > > > > {
> > > > > > ...
> > > > > > switch (foundDep->deptype)
> > > > > > {
> > > > > > ....
> > > > > > case DEPENDENCY_INTERNAL:
> > > > > > * 1. At the outermost recursion level, we must disallow the
> > > > > > * DROP. However, if the owning object is listed in
> > > > > > * pendingObjects, just release the caller's lock and return;
> > > > > > * we'll eventually complete the DROP when we reach that entry
> > > > > > * in the pending list.
> > > > > > }
> > > > > > }
> > > > > >
> > > > > > [1]
> > > > > > postgres[1333899]=# select * from pg_depend where objid > 16410;
> > > > > > classid | objid | objsubid | refclassid | refobjid | refobjsubid | deptype
> > > > > > ---------+-------+----------+------------+----------+-------------+---------
> > > > > > 1259 | 16420 | 0 | 2615 | 16410 | 0 | n
> > > > > > 1259 | 16420 | 0 | 6100 | 16419 | 0 | i
> > > > > > (4 rows)
> > > > > >
> > > > > > 16420 -> conflict_log_table_16419
> > > > > > 16419 -> subscription
> > > > > > 16410 -> schema s1
> > > > > >
> > > > >
> > > > > One approach could be to use something similar to
> > > > > PERFORM_DELETION_SKIP_EXTENSIONS in our case, but only for recursive
> > > > > drops. The effect would be that 'DROP SCHEMA ... CASCADE' would
> > > > > proceed without error, i.e., it would drop the tables as well without
> > > > > including the subscription in the dependency list. But if we try to
> > > > > drop a table directly (e.g., DROP TABLE CLT), it will still result in:
> > > > > ERROR: cannot drop table because subscription sub1 requires it
> > > > >
> > > >
> > > > I think this way of allowing dropping the conflict table without
> > > > caring for the parent object (subscription) is not a good idea. How
> > > > about creating a dedicated schema, say pg_conflict for the purpose of
> > > > storing conflict tables? This will be similar to the pg_toast schema
> > > > for toast tables. So, similar to that each database will have a
> > > > pg_conflict schema. It prevents the "orphan" problem where a user
> > > > accidentally drops the logging schema but the Subscription is still
> > > > trying to write to it. pg_dump needs to ignore all system schemas
> > > > EXCEPT pg_conflict. This ensures the history is preserved during
> > > > migrations while still protecting the tables from accidental user
> > > > deletion. About permissions, I think we need to set the schema
> > > > permissions so that USAGE is public (so users can SELECT from their
> > > > logs) but CREATE is restricted to the superuser/subscription owner. We
> > > > may need to think some more about permissions.
> > > >
> > > > I also tried to reason out if we can allow storing the conflict table
> > > > in pg_catalog but here are a few reasons why it won't be a good idea.
> > > > I think by default, pg_dump completely ignores the pg_catalog schema.
> > > > It assumes pg_catalog contains static system definitions (like
> > > > pg_class, pg_proc, etc.) that are re-generated by the initdb process,
> > > > not user data. If we place a conflict table in pg_catalog, it will not
> > > > be backed up. If a user runs pg_dump/all to migrate to a new server,
> > > > their subscription definition will survive, but their entire history
> > > > of conflict logs will vanish. Also from the permissions angle, If a
> > > > user wants to write a custom PL/pgSQL function to "retry" conflicts,
> > > > they might need to DELETE rows from the conflict table after fixing
> > > > them. Granting DELETE permissions on a table inside pg_catalog is
> > > > non-standard and often frowned upon by security auditors. It blurs the
> > > > line between "System Internals" (immutable) and "User Data" (mutable).
> > > > So, in short a separate pg_conflict schema appears to be a better solution.
> > >
> > > Yeah that makes sense. Although I haven't thought about all cases
> > > whether it can be a problem anywhere, but meanwhile I tried
> > > prototyping with this and it behaves what we want.
> > >
> > > postgres[1651968]=# select * from pg_conflict.conflict_log_table_16406 ;
> > > relid | schemaname | relname | conflict_type | remote_xid |
> > > remote_commit_lsn | remote_commit_ts | remote_origin |
> > > replica_identity | remote_tuple
> > > |
> > > local_conflicts
> > > -------+------------+---------+-----------------------+------------+-------------------+-------------------------------+---------------+------------------+----------------
> > > +------------------------------------------------------------------------------------------------------------------------------------
> > > 16385 | public | test | update_origin_differs | 761 |
> > > 0/01760BD8 | 2025-12-23 11:08:30.583816+00 | pg_16406 |
> > > {"a":1} | {"a":1,"b":20}
> > > | {"{\"xid\":\"772\",\"commit_ts\":\"2025-12-23T11:08:25.568561+00:00\",\"origin\":null,\"key\":null,\"tuple\":{\"a\":1,\"b\":10}}"}
> > > (1 row)
> > >
> > > -- Case1: Alter is not allowed
> > > postgres[1651968]=# ALTER TABLE pg_conflict.conflict_log_table_16406
> > > ADD COLUMN a int;
> > > ERROR: 42501: permission denied: "conflict_log_table_16406" is a system catalog
> > > LOCATION: RangeVarCallbackForAlterRelation, tablecmds.c:19634
> > >
> >
> > How was this achieved? Did you modify IsSystemClass to behave
> > similarly to IsToastClass?
>
> Right
>
> > I tried to analyze whether there are alternative approaches. The
> > possible options I see are:
> >
> > 1)
> > heap_create_with_catalog() provides the boolean argument use_user_acl,
> > which is meant to apply user-defined default privileges. In theory, we
> > could predefine default ACLs for our schema and then invoke
> > heap_create_with_catalog() with use_user_acl = true. But it’s not
> > clear how to do this purely from internal code. We would need to mimic
> > or reuse the logic behind SetDefaultACLsInSchemas.
> > 2)
> > Another option is to create the table using heap_create_with_catalog()
> > with use_user_acl = false, and then explicitly update pg_class.relacl
> > for that table, similar to what ExecGrant_Relation does when
> > processing GRANT/REVOKE. But I couldn’t find any existing internal
> > code paths (outside of the GRANT/REVOKE implementation itself) that do
> > this kind of post-creation ACL manipulation.
>
> I haven't analyzed this options, I will do that but not before Jan 3rd
> as I will be away from my laptop for a week.
>
> > So overall, I feel changing IsSystemClass is the simpler way right
> > now. To set ACL before/after/during heap_create_with_catalog is a
> > tricky thing, at-least I could not find an easier way to do this,
> > unless I have missed something.
> > Thoughts on possible approaches?
>
> Here is the patches I have changed by using IsSystemClass(), based on
> this many other things changed like we don't need to check for the
> temp schema and also the caller of create_conflict_log_table() now
> don't need to find the creation schema so it don't need to generate
> the relname so that part is also moved within
> create_conflict_log_table(). Fixed most of the comments given by
> Peter and Shveta, although some of them are still open e.g. the name
> of the conflict log table as of now I have kept as
> conflict_log_table_<subid> other options are
>
> 1. pg_conflict_<subid>
> 2. conflict_log_<subid>
> 3. sub_conflict_log_<subid>
Few comments:
1) We are allowing to create publication for pg_conflict schema:
postgres=# create publication pub1 for tables in schema pg_conflict ;
CREATE PUBLICATION
postgres=# select * from pg_namespace where nspname = 'pg_conflict';
oid | nspname | nspowner | nspacl
------+-------------+----------+--------
1382 | pg_conflict | 10 |
(1 row)
postgres=# select * from pg_publication_namespace ;
oid | pnpubid | pnnspid
-------+---------+---------
16407 | 16406 | 1382
(1 row)
I'm not sure if it should be allowed as it is mainly for storing
conflict table and the conflict tables will not be replicated.
2) maybe_reread_subscription should check for conflict_log_destination
also and restart if there is a change. If restart is not required, it
can be mentioned in the comments above the old and current sub values
like how it is mentioned for streaming.
3) We should include displaying of subscription conflict table or
conflict relation id also here:
@@ -6900,15 +6900,22 @@ describeSubscriptions(const char *pattern, bool verbose)
appendPQExpBuffer(&buf,
",
subskiplsn AS \"%s\"\n",
gettext_noop("Skip LSN"));
+
+ /* Conflict log destination is supported in v19 and higher */
+ if (pset.sversion >= 190000)
+ {
+ appendPQExpBuffer(&buf,
+ ",
subconflictlogdest AS \"%s\"\n",
+
gettext_noop("Conflict log destination"));
+ }
4) The following includes are not required, I was able to compile
without it in subscriptioncmds.c:
#include "catalog/pg_subscription_rel.h"
#include "catalog/pg_type.h"
+#include "commands/comment.h"
and
#include "utils/lsyscache.h"
#include "utils/memutils.h"
#include "utils/pg_lsn.h"
+#include "utils/regproc.h"
5) Here dest variable is not requried, we can directly set the return
value of GetLogDestination to opts->logdest:
+ else if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DEST) &&
+ strcmp(defel->defname,
"conflict_log_destination") == 0)
+ {
+ char *val;
+ ConflictLogDest dest;
+
+ if (IsSet(opts->specified_opts,
SUBOPT_CONFLICT_LOG_DEST))
+ errorConflictingDefElem(defel, pstate);
+
+ val = defGetString(defel);
+ dest = GetLogDestination(val);
+ opts->logdest = dest;
+ opts->specified_opts |= SUBOPT_CONFLICT_LOG_DEST;
+ }
6) Few of the column are not null columns, should that be defined here:
+/* Structure to hold metadata for one column of the conflict log table */
+typedef struct ConflictLogColumnDef
+{
+ const char *attname; /* Column name */
+ Oid atttypid; /* Data type OID */
+} ConflictLogColumnDef;
+
+/* The single source of truth for the conflict log table schema */
+static const ConflictLogColumnDef ConflictLogSchema[] =
+{
+ {.attname = "relid",.atttypid = OIDOID},
+ {.attname = "schemaname",.atttypid = TEXTOID},
+ {.attname = "relname",.atttypid = TEXTOID},
Regards,
Vignesh
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-02 04:21 vignesh C <vignesh21@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
5 siblings, 0 replies; 912+ messages in thread
From: vignesh C @ 2026-01-02 04:21 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, 25 Dec 2025 at 13:10, Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Wed, Dec 24, 2025 at 4:02 PM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > On Tue, Dec 23, 2025 at 5:52 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > On Tue, Dec 23, 2025 at 5:18 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > > >
> > > > On Tue, Dec 23, 2025 at 10:55 AM shveta malik <shveta.malik@gmail.com> wrote:
> > > > >
> > > > > On Mon, Dec 22, 2025 at 9:11 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > > > >
> > > > > > On Mon, Dec 22, 2025 at 3:09 PM shveta malik <shveta.malik@gmail.com> wrote:
> > > > > >
> > > > > > I think this needs more thought, others can be fixed.
> > > > > >
> > > > > > > 2)
> > > > > > > postgres=# drop schema shveta cascade;
> > > > > > > NOTICE: drop cascades to subscription sub1
> > > > > > > ERROR: global objects cannot be deleted by doDeletion
> > > > > > >
> > > > > > > Is this expected? Is the user supposed to see this error?
> > > > > > >
> > > > > > See below code, so this says if the object being dropped is the
> > > > > > outermost object (i.e. if we are dropping the table directly) then it
> > > > > > will disallow dropping the object on which it has INTERNAL DEPENDENCY,
> > > > > > OTOH if the object is being dropped via recursive drop (i.e. the table
> > > > > > is being dropped while dropping the schema) then object on which it
> > > > > > has INTERNAL dependency will also be added to the deletion list and
> > > > > > later will be dropped via doDeletion and later we are getting error as
> > > > > > subscription is a global object. I thought maybe we can handle an
> > > > > > additional case that the INTERNAL DEPENDENCY, is on subscription the
> > > > > > disallow dropping it irrespective of whether it is being called
> > > > > > directly or via recursive drop but then it will give an issue even
> > > > > > when we are trying to drop table during subscription drop, we can make
> > > > > > handle this case as well via 'flags' passed in findDependentObjects()
> > > > > > but need more investigation.
> > > > > >
> > > > > > Seeing this complexity makes me think more on is it really worth it to
> > > > > > maintain this dependency? Because during subscription drop we anyway
> > > > > > have to call performDeletion externally because this dependency is
> > > > > > local so we are just disallowing the conflict table drop, however the
> > > > > > ALTER table is allowed so what we are really protecting by protecting
> > > > > > the table drop, I think it can be just documented that if user try to
> > > > > > drop the table then conflict will not be inserted anymore?
> > > > > >
> > > > > > findDependentObjects()
> > > > > > {
> > > > > > ...
> > > > > > switch (foundDep->deptype)
> > > > > > {
> > > > > > ....
> > > > > > case DEPENDENCY_INTERNAL:
> > > > > > * 1. At the outermost recursion level, we must disallow the
> > > > > > * DROP. However, if the owning object is listed in
> > > > > > * pendingObjects, just release the caller's lock and return;
> > > > > > * we'll eventually complete the DROP when we reach that entry
> > > > > > * in the pending list.
> > > > > > }
> > > > > > }
> > > > > >
> > > > > > [1]
> > > > > > postgres[1333899]=# select * from pg_depend where objid > 16410;
> > > > > > classid | objid | objsubid | refclassid | refobjid | refobjsubid | deptype
> > > > > > ---------+-------+----------+------------+----------+-------------+---------
> > > > > > 1259 | 16420 | 0 | 2615 | 16410 | 0 | n
> > > > > > 1259 | 16420 | 0 | 6100 | 16419 | 0 | i
> > > > > > (4 rows)
> > > > > >
> > > > > > 16420 -> conflict_log_table_16419
> > > > > > 16419 -> subscription
> > > > > > 16410 -> schema s1
> > > > > >
> > > > >
> > > > > One approach could be to use something similar to
> > > > > PERFORM_DELETION_SKIP_EXTENSIONS in our case, but only for recursive
> > > > > drops. The effect would be that 'DROP SCHEMA ... CASCADE' would
> > > > > proceed without error, i.e., it would drop the tables as well without
> > > > > including the subscription in the dependency list. But if we try to
> > > > > drop a table directly (e.g., DROP TABLE CLT), it will still result in:
> > > > > ERROR: cannot drop table because subscription sub1 requires it
> > > > >
> > > >
> > > > I think this way of allowing dropping the conflict table without
> > > > caring for the parent object (subscription) is not a good idea. How
> > > > about creating a dedicated schema, say pg_conflict for the purpose of
> > > > storing conflict tables? This will be similar to the pg_toast schema
> > > > for toast tables. So, similar to that each database will have a
> > > > pg_conflict schema. It prevents the "orphan" problem where a user
> > > > accidentally drops the logging schema but the Subscription is still
> > > > trying to write to it. pg_dump needs to ignore all system schemas
> > > > EXCEPT pg_conflict. This ensures the history is preserved during
> > > > migrations while still protecting the tables from accidental user
> > > > deletion. About permissions, I think we need to set the schema
> > > > permissions so that USAGE is public (so users can SELECT from their
> > > > logs) but CREATE is restricted to the superuser/subscription owner. We
> > > > may need to think some more about permissions.
> > > >
> > > > I also tried to reason out if we can allow storing the conflict table
> > > > in pg_catalog but here are a few reasons why it won't be a good idea.
> > > > I think by default, pg_dump completely ignores the pg_catalog schema.
> > > > It assumes pg_catalog contains static system definitions (like
> > > > pg_class, pg_proc, etc.) that are re-generated by the initdb process,
> > > > not user data. If we place a conflict table in pg_catalog, it will not
> > > > be backed up. If a user runs pg_dump/all to migrate to a new server,
> > > > their subscription definition will survive, but their entire history
> > > > of conflict logs will vanish. Also from the permissions angle, If a
> > > > user wants to write a custom PL/pgSQL function to "retry" conflicts,
> > > > they might need to DELETE rows from the conflict table after fixing
> > > > them. Granting DELETE permissions on a table inside pg_catalog is
> > > > non-standard and often frowned upon by security auditors. It blurs the
> > > > line between "System Internals" (immutable) and "User Data" (mutable).
> > > > So, in short a separate pg_conflict schema appears to be a better solution.
> > >
> > > Yeah that makes sense. Although I haven't thought about all cases
> > > whether it can be a problem anywhere, but meanwhile I tried
> > > prototyping with this and it behaves what we want.
> > >
> > > postgres[1651968]=# select * from pg_conflict.conflict_log_table_16406 ;
> > > relid | schemaname | relname | conflict_type | remote_xid |
> > > remote_commit_lsn | remote_commit_ts | remote_origin |
> > > replica_identity | remote_tuple
> > > |
> > > local_conflicts
> > > -------+------------+---------+-----------------------+------------+-------------------+-------------------------------+---------------+------------------+----------------
> > > +------------------------------------------------------------------------------------------------------------------------------------
> > > 16385 | public | test | update_origin_differs | 761 |
> > > 0/01760BD8 | 2025-12-23 11:08:30.583816+00 | pg_16406 |
> > > {"a":1} | {"a":1,"b":20}
> > > | {"{\"xid\":\"772\",\"commit_ts\":\"2025-12-23T11:08:25.568561+00:00\",\"origin\":null,\"key\":null,\"tuple\":{\"a\":1,\"b\":10}}"}
> > > (1 row)
> > >
> > > -- Case1: Alter is not allowed
> > > postgres[1651968]=# ALTER TABLE pg_conflict.conflict_log_table_16406
> > > ADD COLUMN a int;
> > > ERROR: 42501: permission denied: "conflict_log_table_16406" is a system catalog
> > > LOCATION: RangeVarCallbackForAlterRelation, tablecmds.c:19634
> > >
> >
> > How was this achieved? Did you modify IsSystemClass to behave
> > similarly to IsToastClass?
>
> Right
>
> > I tried to analyze whether there are alternative approaches. The
> > possible options I see are:
> >
> > 1)
> > heap_create_with_catalog() provides the boolean argument use_user_acl,
> > which is meant to apply user-defined default privileges. In theory, we
> > could predefine default ACLs for our schema and then invoke
> > heap_create_with_catalog() with use_user_acl = true. But it’s not
> > clear how to do this purely from internal code. We would need to mimic
> > or reuse the logic behind SetDefaultACLsInSchemas.
> > 2)
> > Another option is to create the table using heap_create_with_catalog()
> > with use_user_acl = false, and then explicitly update pg_class.relacl
> > for that table, similar to what ExecGrant_Relation does when
> > processing GRANT/REVOKE. But I couldn’t find any existing internal
> > code paths (outside of the GRANT/REVOKE implementation itself) that do
> > this kind of post-creation ACL manipulation.
>
> I haven't analyzed this options, I will do that but not before Jan 3rd
> as I will be away from my laptop for a week.
>
> > So overall, I feel changing IsSystemClass is the simpler way right
> > now. To set ACL before/after/during heap_create_with_catalog is a
> > tricky thing, at-least I could not find an easier way to do this,
> > unless I have missed something.
> > Thoughts on possible approaches?
>
> Here is the patches I have changed by using IsSystemClass(), based on
> this many other things changed like we don't need to check for the
> temp schema and also the caller of create_conflict_log_table() now
> don't need to find the creation schema so it don't need to generate
> the relname so that part is also moved within
> create_conflict_log_table(). Fixed most of the comments given by
> Peter and Shveta, although some of them are still open e.g. the name
> of the conflict log table as of now I have kept as
> conflict_log_table_<subid> other options are
>
> 1. pg_conflict_<subid>
> 2. conflict_log_<subid>
> 3. sub_conflict_log_<subid>
Few comments:
1) I felt this code cannot be reached now, as the table cannot be
dropped by user and if the table does not exist, error will be thrown
from relation_open:
+ conflictlogrel = table_open(conflictlogrelid, RowExclusiveLock);
+
+ /* Conflict log table is dropped or not accessible. */
+ if (conflictlogrel == NULL)
+ ereport(WARNING,
+ (errcode(ERRCODE_UNDEFINED_TABLE),
+ errmsg("conflict log table with OID
%u does not exist",
+ conflictlogrelid)));
2) Since altering of the table is not possible, this table validation
check can be removed:
+ /* Insert to table if destination is 'table' or 'all' */
+ if (conflictlogrel)
+ {
+ Assert((dest & CONFLICT_LOG_DEST_TABLE) != 0);
+
+ if (ValidateConflictLogTable(conflictlogrel))
+ {
3) This function also can be removed:
+/*
+ * ValidateConflictLogTable - Validate conflict log table schema
+ *
+ * Checks whether the table definition including its column names, data
+ * types, and column ordering meet the requirements for conflict log
+ * table.
+ */
+bool
+ValidateConflictLogTable(Relation rel)
4) Similarly here too this check is not required:
+ /* Open conflict log table and insert
the tuple. */
+ conflictlogrel = GetConflictLogTableInfo(&dest);
+ if (ValidateConflictLogTable(conflictlogrel))
+ InsertConflictLogTuple(conflictlogrel);
5) Here jsonb should be included before fmgroids header to maintain
the ordering:
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/lsyscache.h"
+#include "utils/pg_lsn.h"
+#include "utils/jsonb.h"
Regards,
Vignesh
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-02 06:36 shveta malik <shveta.malik@gmail.com>
parent: vignesh C <vignesh21@gmail.com>
0 siblings, 2 replies; 912+ messages in thread
From: shveta malik @ 2026-01-02 06:36 UTC (permalink / raw)
To: vignesh C <vignesh21@gmail.com>; +Cc: Dilip Kumar <dilipbalaut@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Fri, Dec 26, 2025 at 8:58 PM vignesh C <vignesh21@gmail.com> wrote:
>
>
> Here is a rebased version of the remaining patches.
>
Thank You Vignesh. Please find a few comments on 004:
1)
IIUC, SubscriptionConflictrelIndexId is an unique index on sub-oid and
conf-relid, but we use it only on relid as key. Why didn't we create
it only on 'conf-relid' alone? Using a composite unique index is
guaranteed to give unique row only when all keys are used, but for a
single key, a unique row is not guaranteed. In our case, it will be a
unique row as conflict-relid is not shared, but still as an overall
general concept, it may not be.
2)
IsConflictLogTable():
+ if (OidIsValid(subform->subconflictlogrelid))
Do we need this check? Since we’ve already performed an index access
using subconflictlogrelid as the key, isn’t it guaranteed to always be
valid?
3)
Please update the commit message to indicate that this patch makes CLT
publishable if a publication is explicitly created on it, else few
changes become very confusing due to unclear intent.
4)
pg_relation_is_publishable():
/* Subscription conflict log tables are not published */
- result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple)) &&
- !IsConflictLogTable(relid);
Comment should be removed too.
5)
We need to remove below comment:
* Note: Conflict log tables are not publishable. However, we intentionally
* skip this check here because this function is called for every change and
* performing this check during every change publication is costly. To ensure
* unpublishable entries are ignored without incurring performance overhead,
* tuples inserted into the conflict log table uses the HEAP_INSERT_NO_LOGICAL
* flag. This allows the decoding layer to bypass these entries automatically.
*/
bool
is_publishable_relation(Relation rel)
6)
get_rel_sync_entry:
+ /* is this relation used for conflict logging? */
+ isconflictlogrel = IsConflictLogTable(relid);
Shall we add a comment indicating the intent of change in this
function. Something like:
/*
* Check whether this is a conflict log table. If so, avoid publishing it via
* FOR ALL TABLES or FOR TABLES IN SCHEMA publications, but still allow it
* to be published through a publication explicitly created for this table.
*/
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-02 12:23 vignesh C <vignesh21@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
5 siblings, 0 replies; 912+ messages in thread
From: vignesh C @ 2026-01-02 12:23 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Thu, 25 Dec 2025 at 13:10, Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Wed, Dec 24, 2025 at 4:02 PM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > On Tue, Dec 23, 2025 at 5:52 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > On Tue, Dec 23, 2025 at 5:18 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> > > >
> > > > On Tue, Dec 23, 2025 at 10:55 AM shveta malik <shveta.malik@gmail.com> wrote:
> > > > >
> > > > > On Mon, Dec 22, 2025 at 9:11 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > > > >
> > > > > > On Mon, Dec 22, 2025 at 3:09 PM shveta malik <shveta.malik@gmail.com> wrote:
> > > > > >
> > > > > > I think this needs more thought, others can be fixed.
> > > > > >
> > > > > > > 2)
> > > > > > > postgres=# drop schema shveta cascade;
> > > > > > > NOTICE: drop cascades to subscription sub1
> > > > > > > ERROR: global objects cannot be deleted by doDeletion
> > > > > > >
> > > > > > > Is this expected? Is the user supposed to see this error?
> > > > > > >
> > > > > > See below code, so this says if the object being dropped is the
> > > > > > outermost object (i.e. if we are dropping the table directly) then it
> > > > > > will disallow dropping the object on which it has INTERNAL DEPENDENCY,
> > > > > > OTOH if the object is being dropped via recursive drop (i.e. the table
> > > > > > is being dropped while dropping the schema) then object on which it
> > > > > > has INTERNAL dependency will also be added to the deletion list and
> > > > > > later will be dropped via doDeletion and later we are getting error as
> > > > > > subscription is a global object. I thought maybe we can handle an
> > > > > > additional case that the INTERNAL DEPENDENCY, is on subscription the
> > > > > > disallow dropping it irrespective of whether it is being called
> > > > > > directly or via recursive drop but then it will give an issue even
> > > > > > when we are trying to drop table during subscription drop, we can make
> > > > > > handle this case as well via 'flags' passed in findDependentObjects()
> > > > > > but need more investigation.
> > > > > >
> > > > > > Seeing this complexity makes me think more on is it really worth it to
> > > > > > maintain this dependency? Because during subscription drop we anyway
> > > > > > have to call performDeletion externally because this dependency is
> > > > > > local so we are just disallowing the conflict table drop, however the
> > > > > > ALTER table is allowed so what we are really protecting by protecting
> > > > > > the table drop, I think it can be just documented that if user try to
> > > > > > drop the table then conflict will not be inserted anymore?
> > > > > >
> > > > > > findDependentObjects()
> > > > > > {
> > > > > > ...
> > > > > > switch (foundDep->deptype)
> > > > > > {
> > > > > > ....
> > > > > > case DEPENDENCY_INTERNAL:
> > > > > > * 1. At the outermost recursion level, we must disallow the
> > > > > > * DROP. However, if the owning object is listed in
> > > > > > * pendingObjects, just release the caller's lock and return;
> > > > > > * we'll eventually complete the DROP when we reach that entry
> > > > > > * in the pending list.
> > > > > > }
> > > > > > }
> > > > > >
> > > > > > [1]
> > > > > > postgres[1333899]=# select * from pg_depend where objid > 16410;
> > > > > > classid | objid | objsubid | refclassid | refobjid | refobjsubid | deptype
> > > > > > ---------+-------+----------+------------+----------+-------------+---------
> > > > > > 1259 | 16420 | 0 | 2615 | 16410 | 0 | n
> > > > > > 1259 | 16420 | 0 | 6100 | 16419 | 0 | i
> > > > > > (4 rows)
> > > > > >
> > > > > > 16420 -> conflict_log_table_16419
> > > > > > 16419 -> subscription
> > > > > > 16410 -> schema s1
> > > > > >
> > > > >
> > > > > One approach could be to use something similar to
> > > > > PERFORM_DELETION_SKIP_EXTENSIONS in our case, but only for recursive
> > > > > drops. The effect would be that 'DROP SCHEMA ... CASCADE' would
> > > > > proceed without error, i.e., it would drop the tables as well without
> > > > > including the subscription in the dependency list. But if we try to
> > > > > drop a table directly (e.g., DROP TABLE CLT), it will still result in:
> > > > > ERROR: cannot drop table because subscription sub1 requires it
> > > > >
> > > >
> > > > I think this way of allowing dropping the conflict table without
> > > > caring for the parent object (subscription) is not a good idea. How
> > > > about creating a dedicated schema, say pg_conflict for the purpose of
> > > > storing conflict tables? This will be similar to the pg_toast schema
> > > > for toast tables. So, similar to that each database will have a
> > > > pg_conflict schema. It prevents the "orphan" problem where a user
> > > > accidentally drops the logging schema but the Subscription is still
> > > > trying to write to it. pg_dump needs to ignore all system schemas
> > > > EXCEPT pg_conflict. This ensures the history is preserved during
> > > > migrations while still protecting the tables from accidental user
> > > > deletion. About permissions, I think we need to set the schema
> > > > permissions so that USAGE is public (so users can SELECT from their
> > > > logs) but CREATE is restricted to the superuser/subscription owner. We
> > > > may need to think some more about permissions.
> > > >
> > > > I also tried to reason out if we can allow storing the conflict table
> > > > in pg_catalog but here are a few reasons why it won't be a good idea.
> > > > I think by default, pg_dump completely ignores the pg_catalog schema.
> > > > It assumes pg_catalog contains static system definitions (like
> > > > pg_class, pg_proc, etc.) that are re-generated by the initdb process,
> > > > not user data. If we place a conflict table in pg_catalog, it will not
> > > > be backed up. If a user runs pg_dump/all to migrate to a new server,
> > > > their subscription definition will survive, but their entire history
> > > > of conflict logs will vanish. Also from the permissions angle, If a
> > > > user wants to write a custom PL/pgSQL function to "retry" conflicts,
> > > > they might need to DELETE rows from the conflict table after fixing
> > > > them. Granting DELETE permissions on a table inside pg_catalog is
> > > > non-standard and often frowned upon by security auditors. It blurs the
> > > > line between "System Internals" (immutable) and "User Data" (mutable).
> > > > So, in short a separate pg_conflict schema appears to be a better solution.
> > >
> > > Yeah that makes sense. Although I haven't thought about all cases
> > > whether it can be a problem anywhere, but meanwhile I tried
> > > prototyping with this and it behaves what we want.
> > >
> > > postgres[1651968]=# select * from pg_conflict.conflict_log_table_16406 ;
> > > relid | schemaname | relname | conflict_type | remote_xid |
> > > remote_commit_lsn | remote_commit_ts | remote_origin |
> > > replica_identity | remote_tuple
> > > |
> > > local_conflicts
> > > -------+------------+---------+-----------------------+------------+-------------------+-------------------------------+---------------+------------------+----------------
> > > +------------------------------------------------------------------------------------------------------------------------------------
> > > 16385 | public | test | update_origin_differs | 761 |
> > > 0/01760BD8 | 2025-12-23 11:08:30.583816+00 | pg_16406 |
> > > {"a":1} | {"a":1,"b":20}
> > > | {"{\"xid\":\"772\",\"commit_ts\":\"2025-12-23T11:08:25.568561+00:00\",\"origin\":null,\"key\":null,\"tuple\":{\"a\":1,\"b\":10}}"}
> > > (1 row)
> > >
> > > -- Case1: Alter is not allowed
> > > postgres[1651968]=# ALTER TABLE pg_conflict.conflict_log_table_16406
> > > ADD COLUMN a int;
> > > ERROR: 42501: permission denied: "conflict_log_table_16406" is a system catalog
> > > LOCATION: RangeVarCallbackForAlterRelation, tablecmds.c:19634
> > >
> >
> > How was this achieved? Did you modify IsSystemClass to behave
> > similarly to IsToastClass?
>
> Right
>
> > I tried to analyze whether there are alternative approaches. The
> > possible options I see are:
> >
> > 1)
> > heap_create_with_catalog() provides the boolean argument use_user_acl,
> > which is meant to apply user-defined default privileges. In theory, we
> > could predefine default ACLs for our schema and then invoke
> > heap_create_with_catalog() with use_user_acl = true. But it’s not
> > clear how to do this purely from internal code. We would need to mimic
> > or reuse the logic behind SetDefaultACLsInSchemas.
> > 2)
> > Another option is to create the table using heap_create_with_catalog()
> > with use_user_acl = false, and then explicitly update pg_class.relacl
> > for that table, similar to what ExecGrant_Relation does when
> > processing GRANT/REVOKE. But I couldn’t find any existing internal
> > code paths (outside of the GRANT/REVOKE implementation itself) that do
> > this kind of post-creation ACL manipulation.
>
> I haven't analyzed this options, I will do that but not before Jan 3rd
> as I will be away from my laptop for a week.
>
> > So overall, I feel changing IsSystemClass is the simpler way right
> > now. To set ACL before/after/during heap_create_with_catalog is a
> > tricky thing, at-least I could not find an easier way to do this,
> > unless I have missed something.
> > Thoughts on possible approaches?
>
> Here is the patches I have changed by using IsSystemClass(), based on
> this many other things changed like we don't need to check for the
> temp schema and also the caller of create_conflict_log_table() now
> don't need to find the creation schema so it don't need to generate
> the relname so that part is also moved within
> create_conflict_log_table(). Fixed most of the comments given by
> Peter and Shveta, although some of them are still open e.g. the name
> of the conflict log table as of now I have kept as
> conflict_log_table_<subid> other options are
>
> 1. pg_conflict_<subid>
> 2. conflict_log_<subid>
> 3. sub_conflict_log_<subid>
Few comments:
1) I was thinking it would be good to combine the tests with
035_conflicts, where the subscription can be changed to include
conflict_log_destination and include the additional verification. That
way we might save some execution time.
2) I felt this data is not required for this test:
+##################################################
+# INSERT data on Pub and Sub
+##################################################
+
+# Insert data in the publisher table
+$node_publisher->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (1,1,1);");
+
+# Insert data in the subscriber table
+$node_subscriber->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (2,2,2), (3,3,3), (4,4,4);");
3) You can name it starting with regress_ to avoid warnings when
ENFORCE_REGRESSION_TEST_NAME_RESTRICTIONS is defined:
+$node_subscriber->safe_psql(
+ 'postgres',
+ "CREATE SUBSCRIPTION sub_tab
+ CONNECTION '$publisher_connstr application_name=$appname'
+ PUBLICATION pub_tab WITH (conflict_log_destination=table)");
4) Similarly here too:
+# Setup logical replication
+my $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
+$node_publisher->safe_psql('postgres',
+ "CREATE PUBLICATION pub_tab FOR TABLE conf_tab, conf_tab_2");
5) Since we support conflict logging to logfile and table, we can
mention inserted into clt to make it more clear. That way it will
avoid it to misinterpret with clt logfile logging:
+# Wait for the conflict to be logged
+my $log_check = $node_subscriber->poll_query_until('postgres',
+ "SELECT count(*) > 0 FROM $conflict_table;");
Regards,
Vignesh
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-04 12:21 Dilip Kumar <dilipbalaut@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2026-01-04 12:21 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; vignesh C <vignesh21@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Mon, Dec 29, 2025 at 11:32 AM shveta malik <shveta.malik@gmail.com> wrote:
>
> On Thu, Dec 25, 2025 at 1:10 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > Here is the patches I have changed by using IsSystemClass(), based on
> > this many other things changed like we don't need to check for the
> > temp schema and also the caller of create_conflict_log_table() now
> > don't need to find the creation schema so it don't need to generate
> > the relname so that part is also moved within
> > create_conflict_log_table(). Fixed most of the comments given by
> > Peter and Shveta, although some of them are still open e.g. the name
> > of the conflict log table as of now I have kept as
> > conflict_log_table_<subid> other options are
> >
> > 1. pg_conflict_<subid>
> > 2. conflict_log_<subid>
> > 3. sub_conflict_log_<subid>
> >
> > I prefer 3, considering it says this table holds subscription conflict
> > logs. Thoughts?
> >
>
> I was checking how pg_toast does it. It creates tables with names:
> "pg_toast_%u", relOid
>
> We can do similar i.e., the schema name as pg_conflict and table name
> as pg_conflict_<subid>. Thoughts?
I think this make sense to name the table as pg_conflict_<subid>
> Few comments on 001:
>
>
> 1)
> It will be good to display conflict tablename in \dRs command
+1
> 2)
> postgres=# ALTER TABLE sch1.t3 set schema pg_toast;
> ERROR: cannot move objects into or out of TOAST schema
>
> But when we move to pg_conflict, it works. It should error out as well.
> postgres=# ALTER TABLE sch1.t1 set schema pg_conflict;
> ALTER TABLE
Yeah this should not be allowed and similarly
create/drop/alter/truncate on tables under this schema should be
restricted, I will check all the cases and fix them wherever there are
gaps.
> 3)
> Shall we LOG CLT creation and drop during create/alter sub?
We may but not sure how useful it would be.
> 4)
> create_conflict_log_table()
> + /* Report an error if the specified conflict log table already exists. */
> + if (OidIsValid(get_relname_relid(relname, PG_CONFLICT_NAMESPACE)))
> + ereport(ERROR,
> + (errcode(ERRCODE_DUPLICATE_TABLE),
> + errmsg("relation \"%s.%s\" already exists",
> + get_namespace_name(PG_CONFLICT_NAMESPACE), relname)));
>
>
> I am unable to think of a valid user-scenario when the above will be
> hit. Do we need this as a user-error or simply an Assert or
> internal-error will do?
Yeah it doesn't make sense anymore, I think we can just put an assertion.
> 5)
> + /*
> + * Establish an internal dependency between the conflict log table and the
> + * subscription. By using DEPENDENCY_INTERNAL, we ensure the table is
> + * automatically reaped when the subscription is dropped. This also
> + * prevents the table from being dropped independently unless the
> + * subscription itself is removed.
> + */
> + ObjectAddressSet(myself, RelationRelationId, relid);
> + ObjectAddressSet(subaddr, SubscriptionRelationId, subid);
> + recordDependencyOn(&myself, &subaddr, DEPENDENCY_INTERNAL);
>
> Now that we have pg_conflict, which is treated similarly to a system
> catalog, I’m wondering whether we actually need to maintain this
> dependency to prevent the CLT table or schema from being dropped.
> Also, given that this currently goes against the convention that a
> shared object cannot be present in pg_depend, could DropSubscription()
> and AlterSubscription() instead handle dropping the table explicitly
> in required scenarios?
I thought about it while implementing the catalog schema, but then
left as it is considering pg_toast tables also maintain internal
dependency on the table, having said that, during drop
subscription/alter subscription we anyway have to explicitly call the
performDeletion of the table so seems like we are not achieving
anything by maintaining dependency. Lets see what others have to say
on this? I prefer removing this dependency because this is an
exceptional case where we are maintaining dependency from a local
object to a shared object. And now if we do not have any need for
this we better get rid of it.
> 6)
> + descr => 'reserved schema for conflict tables',
>
> Shall we say: 'reserved schema for subscription-specific conflict tables'
>
> or anything better to include that it is subscription related?
IMHO this makes sense.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-05 04:28 shveta malik <shveta.malik@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: shveta malik @ 2026-01-05 04:28 UTC (permalink / raw)
To: vignesh C <vignesh21@gmail.com>; +Cc: Dilip Kumar <dilipbalaut@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Fri, Jan 2, 2026 at 12:06 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> On Fri, Dec 26, 2025 at 8:58 PM vignesh C <vignesh21@gmail.com> wrote:
> >
> >
> > Here is a rebased version of the remaining patches.
> >
>
> Thank You Vignesh. Please find a few comments on 004:
>
Vignesh, please find a few comments on 005.
1)
AlterSubscriptionConflictLogDestination()
+ if (want_table && !has_oldtable)
+ {
+ char relname[NAMEDATALEN];
+
+ snprintf(relname, NAMEDATALEN, "conflict_log_table_%u", sub->oid);
+
+ relid = get_relname_relid(relname, PG_CONFLICT_NAMESPACE);
+ if (OidIsValid(relid))
We have added this new scenario wherein we check if CLT is present
already and if so, just set it in subid.
a) Where will this scenario be hit? Can we please add the comments?
On trying pg_dump, I see that it does not dump CLT and thus above will
not be hit in pg_dump at least.
b) Even if we have a valid scenario where we have a pre-existing CLT
and sub is created later, how/where are we ensuring that subid in CLT
name will match newly generated subid?
2)
+ if (IsConflictLogTable(relid))
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("conflict log table \"%s.%s\" cannot be used",
+ nspname, relname),
+ errdetail("The specified table is already registered for a different
subscription."),
+ errhint("Specify a different conflict log table."));
a) Since the user is not specifying the CLT name, errhint seems incorrect.
b) Again, I am unable to understand when this error will be hit? Since
CLT is internally created using subid of owning subscription, how CLT
of a particular subid be connected to subscription of different subid
to result in above error? Can you please add comments to explain the
situation.
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-05 06:12 Dilip Kumar <dilipbalaut@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 0 replies; 912+ messages in thread
From: Dilip Kumar @ 2026-01-05 06:12 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, Dec 23, 2025 at 3:34 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> On Mon, Dec 22, 2025 at 4:01 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > Done in V15
>
> Thanks for the patches. A few comments on v15-002 for the part I have
> reviewed so far:
>
> 1)
> Defined twice:
>
> +#define MAX_LOCAL_CONFLICT_INFO_ATTRS 5
>
> +#define MAX_LOCAL_CONFLICT_INFO_ATTRS \
> + (sizeof(LocalConflictSchema) / sizeof(LocalConflictSchema[0]))
Fixed
> 2)
> GetConflictLogTableInfo:
> + *log_dest = GetLogDestination(MySubscription->logdestination);
> + conflictlogrelid = MySubscription->conflictrelid;
> +
> + /* If destination is 'log' only, no table to open. */
> + if (*log_dest == CONFLICT_LOG_DEST_LOG)
> + return NULL;
>
> We can get conflictlogrelid after the if-check for DEST_LOG.
I am planning to do some more refactoring considering other comments,
based on that we need to first get the destination so flow will be
like
{
....
conflictlogrel = GetConflictLogTableInfo(&dest);
if ((dest & CONFLICT_LOG_DEST_TABLE) != 0)
{
/* insert into table */
}
if ((dest & CONFLICT_LOG_DEST_LOG) != 0)
{
-- Prepare error details
-- Log with error detail
}
else
{
-- log basic information
}
> 3)
> In ReportApplyConflict(), we form err_detail by calling
> errdetail_apply_conflict(). But when dest is TABLE, we don't use
> err_detail. Shall we skip creating it for dest=TABLE case?
Righ, the above refactoring will take care of this as well
> 4)
> ReportApplyConflict():
> + /*
> + * Get both the conflict log destination and the opened conflict log
> + * relation for insertion.
> + */
> + conflictlogrel = GetConflictLogTableInfo(&dest);
> +
>
> We can move it after errdetail_apply_conflict(), closer to where we
> actually use it.
Same as above
> 5)
> start_apply:
> + /* Open conflict log table and insert the tuple. */
> + conflictlogrel = GetConflictLogTableInfo(&dest);
> + if (ValidateConflictLogTable(conflictlogrel))
> + InsertConflictLogTuple(conflictlogrel);
>
> We can have Assert here too before we call Validate:
> Assert(dest == CONFLICT_LOG_DEST_TABLE || dest == CONFLICT_LOG_DEST_ALL);
Done, I think now we can get rid of ValidateConflictLogTable() as well
because table can not be modified now by user.
> 6)
> start_apply:
> + if (ValidateConflictLogTable(conflictlogrel))
> + InsertConflictLogTuple(conflictlogrel);
> + MyLogicalRepWorker->conflict_log_tuple = NULL;
>
> InsertConflictLogTuple() already sets conflict_log_tuple to NULL.
> Above is not needed.
Make sense.
These fixes will be available in next version.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-05 06:41 Dilip Kumar <dilipbalaut@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2026-01-05 06:41 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; vignesh C <vignesh21@gmail.com>
On Thu, Jan 1, 2026 at 4:01 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> Few comments on v17-003.
> <The doc does not compile.>
>
> logical-replication.sgml
> 1)
> + <link linkend="sql-dropsubscription"><command>DROP
> SUBSCRIPTION</command></link>. When the
> + <literal>conflict_log_destination</literal> parameter is set to
> <literal>table</literal>
> + or <literal>all</literal>, the system automatically manages a
> dedicated conflict
> + storage table. This table is dropped automatically when the subscription is
> + removed via <command>DROP SUBSCRIPTION</command>.
>
> + <para>
> + Conflicts that occur during replication are typically logged as plain text
> + in the server log, which can be difficult for automated monitoring and
> + analysis. The <command>CREATE SUBSCRIPTION</command> command provides the
> + <link linkend="sql-createsubscription-params-with-conflict-log-destination">
> + <literal>conflict_log_destination</literal></link> option to record detailed
> + conflict information in a structured, queryable format, significantly
> + improving post-mortem analysis and operational visibility of the replication
> + setup by logging to a system-managed table.
> +
>
> Do we really need these at 2 places in the same section? The 2nd
> paragraph can be tweaked to include the first one and placed at the
> end of that section. How about:
>
> Conflicts that occur during replication are, by default, logged as plain text
> in the server log, which can make automated monitoring and analysis difficult.
> The <command>CREATE SUBSCRIPTION</command> command provides the
> <link linkend="sql-createsubscription-params-with-conflict-log-destination">
> <literal>conflict_log_destination</literal></link> option to record detailed
> conflict information in a structured, queryable format. When this parameter
> is set to <literal>table</literal> or <literal>all</literal>, the system
> automatically manages a dedicated conflict storage table, which is created
> and dropped along with the subscription. This significantly improves post-mortem
> analysis and operational visibility of the replication setup.
Yeah I am fine with doing this.
> 2)
> + in the following <firstterm>conflict</firstterm> cases. If the subscription
> + was created with the <literal>conflict_log_destination</literal> set to
> + <literal>table</literal>, detailed conflict information is also inserted
> + into an internally managed table named
>
> + When the <literal>conflict_log_destination</literal> is set to
> + <literal>table</literal>, the system automatically creates a new table with
> + a predefined schema to log conflict details.
>
> Should we mention 'all' also in both of above:
Make sense.
> 3)
> + <literal>pg_conflict.conflict_log_table_<subscription_oid></literal>,
>
> I think we can not write <subscription_oid>, it will look for
> finishing tag </sub..>.
Will fix.
> 4)
> The log format for logical replication conflicts is as follows:
>
> We can even modify this line to something like:
> If <literal>conflict_log_destination</literal> is left at the default
> setting or explicitly configured
> as <literal>log</literal> or <literal>all</literal>, logical
> replication conflicts are logged in the following format:
+1
> 5)
> alter_subscription.sgml:
> +
> + <para>
> + When switching <literal>conflict_log_destination</literal> to
> <literal>table</literal>,
> + the system will ensure the internal logging table exists. If
> switching away
> + from <literal>table</literal>, the logging stops, but the
> previously recorded
> + data remains until the subscription is dropped.
> + </para>
>
> I do not think this info is true. We drop the table when we alter
> conflict_log_destination to set a non-table value.
Will fix this.
> 6)
> In create_subscription.sgml where we talk about conflict log table,
> shall we also point to its structure mentioned in the Conflict page?
I do not understand this, do you mean the schema of the conflict log
table? For that we already have a dedicated section[1]?
<table id="logical-replication-conflict-log-schema">
<title>Conflict Log Table Schema</title>
<tgroup cols="3">
<thead>
<row>
<entry>Column</entry>
<entry>Type</entry>
<entry>Description</entry>
</row>
</thead>
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-05 09:43 Dilip Kumar <dilipbalaut@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2026-01-05 09:43 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; vignesh C <vignesh21@gmail.com>
On Mon, Jan 5, 2026 at 12:11 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Thu, Jan 1, 2026 at 4:01 PM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > Few comments on v17-003.
> > <The doc does not compile.>
> >
> > logical-replication.sgml
> > 1)
> > + <link linkend="sql-dropsubscription"><command>DROP
> > SUBSCRIPTION</command></link>. When the
> > + <literal>conflict_log_destination</literal> parameter is set to
> > <literal>table</literal>
> > + or <literal>all</literal>, the system automatically manages a
> > dedicated conflict
> > + storage table. This table is dropped automatically when the subscription is
> > + removed via <command>DROP SUBSCRIPTION</command>.
> >
> > + <para>
> > + Conflicts that occur during replication are typically logged as plain text
> > + in the server log, which can be difficult for automated monitoring and
> > + analysis. The <command>CREATE SUBSCRIPTION</command> command provides the
> > + <link linkend="sql-createsubscription-params-with-conflict-log-destination">
> > + <literal>conflict_log_destination</literal></link> option to record detailed
> > + conflict information in a structured, queryable format, significantly
> > + improving post-mortem analysis and operational visibility of the replication
> > + setup by logging to a system-managed table.
> > +
> >
> > Do we really need these at 2 places in the same section? The 2nd
> > paragraph can be tweaked to include the first one and placed at the
> > end of that section. How about:
> >
> > Conflicts that occur during replication are, by default, logged as plain text
> > in the server log, which can make automated monitoring and analysis difficult.
> > The <command>CREATE SUBSCRIPTION</command> command provides the
> > <link linkend="sql-createsubscription-params-with-conflict-log-destination">
> > <literal>conflict_log_destination</literal></link> option to record detailed
> > conflict information in a structured, queryable format. When this parameter
> > is set to <literal>table</literal> or <literal>all</literal>, the system
> > automatically manages a dedicated conflict storage table, which is created
> > and dropped along with the subscription. This significantly improves post-mortem
> > analysis and operational visibility of the replication setup.
>
> Yeah I am fine with doing this.
>
> > 2)
> > + in the following <firstterm>conflict</firstterm> cases. If the subscription
> > + was created with the <literal>conflict_log_destination</literal> set to
> > + <literal>table</literal>, detailed conflict information is also inserted
> > + into an internally managed table named
> >
> > + When the <literal>conflict_log_destination</literal> is set to
> > + <literal>table</literal>, the system automatically creates a new table with
> > + a predefined schema to log conflict details.
> >
> > Should we mention 'all' also in both of above:
>
> Make sense.
>
> > 3)
> > + <literal>pg_conflict.conflict_log_table_<subscription_oid></literal>,
> >
> > I think we can not write <subscription_oid>, it will look for
> > finishing tag </sub..>.
>
> Will fix.
>
> > 4)
> > The log format for logical replication conflicts is as follows:
> >
> > We can even modify this line to something like:
> > If <literal>conflict_log_destination</literal> is left at the default
> > setting or explicitly configured
> > as <literal>log</literal> or <literal>all</literal>, logical
> > replication conflicts are logged in the following format:
>
> +1
>
> > 5)
> > alter_subscription.sgml:
> > +
> > + <para>
> > + When switching <literal>conflict_log_destination</literal> to
> > <literal>table</literal>,
> > + the system will ensure the internal logging table exists. If
> > switching away
> > + from <literal>table</literal>, the logging stops, but the
> > previously recorded
> > + data remains until the subscription is dropped.
> > + </para>
> >
> > I do not think this info is true. We drop the table when we alter
> > conflict_log_destination to set a non-table value.
>
> Will fix this.
>
> > 6)
> > In create_subscription.sgml where we talk about conflict log table,
> > shall we also point to its structure mentioned in the Conflict page?
>
> I do not understand this, do you mean the schema of the conflict log
> table? For that we already have a dedicated section[1]?
>
> <table id="logical-replication-conflict-log-schema">
> <title>Conflict Log Table Schema</title>
> <tgroup cols="3">
> <thead>
> <row>
> <entry>Column</entry>
> <entry>Type</entry>
> <entry>Description</entry>
> </row>
> </thead>
Here is the updated version of patch, Vignesh's patches must be
rebased again on the new version.
--
Regards,
Dilip Kumar
Google
Attachments:
[application/octet-stream] v18-0003-Doccumentation-patch.patch (11.3K, ../../CAFiTN-t-TAn9NhfVCC7KMNN=SW5x0o2QwjSc9VzieBixLm9CMA@mail.gmail.com/2-v18-0003-Doccumentation-patch.patch)
download | inline diff:
From 9d1adfb78feea27dc458c3a092c9523da54b163d Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Sun, 21 Dec 2025 18:51:57 +0530
Subject: [PATCH v18 3/3] Doccumentation patch
---
doc/src/sgml/logical-replication.sgml | 124 +++++++++++++++++++++-
doc/src/sgml/ref/alter_subscription.sgml | 14 ++-
doc/src/sgml/ref/create_subscription.sgml | 36 +++++++
3 files changed, 168 insertions(+), 6 deletions(-)
diff --git a/doc/src/sgml/logical-replication.sgml b/doc/src/sgml/logical-replication.sgml
index 58ce75d8b63..a2c66b164a0 100644
--- a/doc/src/sgml/logical-replication.sgml
+++ b/doc/src/sgml/logical-replication.sgml
@@ -289,6 +289,18 @@
option of <command>CREATE SUBSCRIPTION</command> for details.
</para>
+ <para>
+ Conflicts that occur during replication are, by default, logged as plain text
+ in the server log, which can make automated monitoring and analysis difficult.
+ The <command>CREATE SUBSCRIPTION</command> command provides the
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination">
+ <literal>conflict_log_destination</literal></link> option to record detailed
+ conflict information in a structured, queryable format. When this parameter
+ is set to <literal>table</literal> or <literal>all</literal>, the system
+ automatically manages a dedicated conflict storage table, which is created
+ and dropped along with the subscription. This significantly improves post-mortem
+ analysis and operational visibility of the replication setup.
+
<sect2 id="logical-replication-subscription-slot">
<title>Logical Replication Slot Management</title>
@@ -2006,9 +2018,15 @@ Publications:
</para>
<para>
- Additional logging is triggered, and the conflict statistics are collected (displayed in the
- <link linkend="monitoring-pg-stat-subscription-stats"><structname>pg_stat_subscription_stats</structname></link> view)
- in the following <firstterm>conflict</firstterm> cases:
+ Additional logging is triggered, and the conflict statistics are collected
+ (displayed in the <link linkend="monitoring-pg-stat-subscription-stats">
+ <structname>pg_stat_subscription_stats</structname></link> view)
+ in the following <firstterm>conflict</firstterm> cases. If the subscription
+ was created with the <literal>conflict_log_destination</literal> set to
+ <literal>table</literal> or <literal>all</literal>, detailed conflict
+ information is inserted into an internally managed table named
+ <literal>pg_conflict.pg_conflict_<replaceable>subscription_oid</replaceable>
+ </literal>, providing a structured record of all conflicts.
<variablelist>
<varlistentry id="conflict-insert-exists" xreflabel="insert_exists">
<term><literal>insert_exists</literal></term>
@@ -2118,7 +2136,96 @@ Publications:
</para>
<para>
- The log format for logical replication conflicts is as follows:
+ When the <literal>conflict_log_destination</literal> is set to
+ <literal>table</literal> or <literal>all</literal>, the system automatically
+ creates a new table with a predefined schema to log conflict details. This
+ table is created in the dedicated <literal>pg_conflict</literal> namespace.
+ The schema of this table is detailed in
+ <xref linkend="logical-replication-conflict-log-schema"/>.
+ </para>
+
+ <table id="logical-replication-conflict-log-schema">
+ <title>Conflict Log Table Schema</title>
+ <tgroup cols="3">
+ <thead>
+ <row>
+ <entry>Column</entry>
+ <entry>Type</entry>
+ <entry>Description</entry>
+ </row>
+ </thead>
+ <tbody>
+ <row>
+ <entry><literal>relid</literal></entry>
+ <entry><type>oid</type></entry>
+ <entry>The OID of the local table where the conflict occurred.</entry>
+ </row>
+ <row>
+ <entry><literal>schemaname</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The schema name of the conflicting table.</entry>
+ </row>
+ <row>
+ <entry><literal>relname</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The name of the conflicting table.</entry>
+ </row>
+ <row>
+ <entry><literal>conflict_type</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The type of conflict that occurred (e.g., <literal>insert_exists</literal>).</entry>
+ </row>
+ <row>
+ <entry><literal>remote_xid</literal></entry>
+ <entry><type>xid</type></entry>
+ <entry>The remote transaction ID that caused the conflict.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_commit_lsn</literal></entry>
+ <entry><type>pg_lsn</type></entry>
+ <entry>The final LSN of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_commit_ts</literal></entry>
+ <entry><type>timestamptz</type></entry>
+ <entry>The remote commit timestamp of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_origin</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The origin of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_tuple</literal></entry>
+ <entry><type>json</type></entry>
+ <entry>The JSON representation of the incoming remote row that caused the conflict.</entry>
+ </row>
+ <row>
+ <entry><literal>local_conflicts</literal></entry>
+ <entry><type>json[]</type></entry>
+ <entry>
+ An array of JSON objects representing the local state for each conflict attempt.
+ Each object includes the local transaction ID (<literal>xid</literal>), commit
+ timestamp (<literal>commit_ts</literal>), origin (<literal>origin</literal>),
+ conflicting key data (<literal>key</literal>), and the full local row
+ image (<literal>tuple</literal>).
+ </entry>
+ </row>
+ </tbody>
+ </tgroup>
+ </table>
+
+ <para>
+ The conflicting row data, including the original local tuple and
+ the remote tuple, is stored in <type>JSON</type> columns (<literal>local_tuple</literal>
+ and <literal>remote_tuple</literal>) for flexible querying and analysis.
+ </para>
+
+ <para>
+ If <literal>conflict_log_destination</literal> is left at the default
+ setting or explicitly configured as <literal>log</literal> or
+ <literal>all</literal>, logical replication conflicts are logged in the
+ following format:
<synopsis>
LOG: conflict detected on relation "<replaceable>schemaname</replaceable>.<replaceable>tablename</replaceable>": conflict=<replaceable>conflict_type</replaceable>
DETAIL: <replaceable class="parameter">detailed_explanation</replaceable>.
@@ -2412,6 +2519,15 @@ CONTEXT: processing remote data for replication origin "pg_16395" during "INSER
key or replica identity defined for it.
</para>
</listitem>
+
+ <listitem>
+ <para>
+ The internal table automatically created when <literal>conflict_log_destination</literal>
+ is set to <literal>table</literal> or <literal>all</literal> is excluded from
+ logical replication. It will not be published, even if a publication on the
+ subscriber is defined using <literal>FOR ALL TABLES</literal>.
+ </para>
+ </listitem>
</itemizedlist>
</sect1>
diff --git a/doc/src/sgml/ref/alter_subscription.sgml b/doc/src/sgml/ref/alter_subscription.sgml
index 27c06439f4f..90331f590e0 100644
--- a/doc/src/sgml/ref/alter_subscription.sgml
+++ b/doc/src/sgml/ref/alter_subscription.sgml
@@ -280,8 +280,9 @@ ALTER SUBSCRIPTION <replaceable class="parameter">name</replaceable> RENAME TO <
<link linkend="sql-createsubscription-params-with-origin"><literal>origin</literal></link>,
<link linkend="sql-createsubscription-params-with-failover"><literal>failover</literal></link>,
<link linkend="sql-createsubscription-params-with-two-phase"><literal>two_phase</literal></link>,
- <link linkend="sql-createsubscription-params-with-retain-dead-tuples"><literal>retain_dead_tuples</literal></link>, and
- <link linkend="sql-createsubscription-params-with-max-retention-duration"><literal>max_retention_duration</literal></link>.
+ <link linkend="sql-createsubscription-params-with-retain-dead-tuples"><literal>retain_dead_tuples</literal></link>,
+ <link linkend="sql-createsubscription-params-with-max-retention-duration"><literal>max_retention_duration</literal></link> and,
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>.
Only a superuser can set <literal>password_required = false</literal>.
</para>
@@ -339,6 +340,15 @@ ALTER SUBSCRIPTION <replaceable class="parameter">name</replaceable> RENAME TO <
<quote><literal>pg_conflict_detection</literal></quote>, created to retain
dead tuples for conflict detection, will be dropped.
</para>
+
+ <para>
+ When the <literal>conflict_log_destination</literal> parameter is set to
+ <literal>table</literal> or <literal>all</literal>, the system
+ automatically creates the internal logging table if it does not already
+ exist. Conversely, if the destination is changed to
+ <literal>log</literal>, logging to the table stops and the internal
+ table is automatically dropped.
+ </para>
</listitem>
</varlistentry>
diff --git a/doc/src/sgml/ref/create_subscription.sgml b/doc/src/sgml/ref/create_subscription.sgml
index 197be0c6f6b..71119320f16 100644
--- a/doc/src/sgml/ref/create_subscription.sgml
+++ b/doc/src/sgml/ref/create_subscription.sgml
@@ -274,6 +274,42 @@ CREATE SUBSCRIPTION <replaceable class="parameter">subscription_name</replaceabl
</listitem>
</varlistentry>
+ <varlistentry id="sql-createsubscription-params-with-conflict-log-destination">
+ <term><literal>conflict_log_destination</literal> (<type>enum</type>)</term>
+ <listitem>
+ <para>
+ Specifies the destination for recording logical replication conflicts.
+ The supported values are <literal>log</literal>, <literal>table</literal>,
+ and <literal>all</literal>. The default is <literal>log</literal>.
+ </para>
+ <para>
+ The available destinations are:
+ <itemizedlist>
+ <listitem>
+ <para>
+ <literal>log</literal>: Conflict details are recorded in the server log.
+ This is the default behavior.
+ </para>
+ </listitem>
+ <listitem>
+ <para>
+ <literal>table</literal>: The system automatically creates a structured table
+ named <literal>pg_conflict.conflict_log_table_<subid></literal>.
+ This allows for easy querying and analysis of conflicts. This table is
+ automatically dropped when the subscription is removed.
+ </para>
+ </listitem>
+ <listitem>
+ <para>
+ <literal>all</literal>: Records the conflict information to both the server log
+ and the dedicated conflict table.
+ </para>
+ </listitem>
+ </itemizedlist>
+ </para>
+ </listitem>
+ </varlistentry>
+
<varlistentry id="sql-createsubscription-params-with-streaming">
<term><literal>streaming</literal> (<type>enum</type>)</term>
<listitem>
--
2.49.0
[application/octet-stream] v18-0001-Add-configurable-conflict-log-table-for-Logical-.patch (101.0K, ../../CAFiTN-t-TAn9NhfVCC7KMNN=SW5x0o2QwjSc9VzieBixLm9CMA@mail.gmail.com/3-v18-0001-Add-configurable-conflict-log-table-for-Logical-.patch)
download | inline diff:
From 3ec478376d03dde43ca04c5fa824890899094170 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 17 Dec 2025 11:53:47 +0530
Subject: [PATCH v18 1/3] Add configurable conflict log table for Logical
Replication
This patch adds a feature to provide a structured, queryable record of all
logical replication conflicts. The current approach of logging conflicts as
plain text in the server logs makes it difficult to query, analyze, and
use for external monitoring and automation.
This patch addresses these limitations by introducing a configurable
conflict_log_destination=('log'/'table'/'all') option in the CREATE SUBSCRIPTION
command.
If the user chooses to enable logging to a table (by selecting 'table' or 'all'),
an internal logging table named conflict_log_table_<subid> is automatically
created within a dedicated, system-managed namespace named pg_conflict. This table
is linked to the subscription via an internal dependency, ensuring it is
automatically dropped when the subscription is removed.
The conflict details, including the original and remote tuples, are stored in JSON
columns, providing a flexible format to accommodate different table schemas.
The log table captures essential attributes such as local and remote transaction IDs,
LSNs, commit timestamps, and conflict type, providing a complete record for post-mortem
analysis.
This feature will make logical replication conflicts easier to monitor and manage,
significantly improving the overall resilience and operability of replication setups.
The conflict log tables will not be included in a publication, even if the publication
is configured to include ALL TABLES or ALL TABLES IN SCHEMA.
---
src/backend/catalog/catalog.c | 27 +-
src/backend/catalog/heap.c | 3 +-
src/backend/catalog/namespace.c | 6 +
src/backend/catalog/pg_publication.c | 26 +-
src/backend/catalog/pg_subscription.c | 7 +
src/backend/commands/subscriptioncmds.c | 284 ++++++++++++++++-
src/bin/psql/describe.c | 21 +-
src/bin/psql/tab-complete.in.c | 8 +-
src/include/catalog/catalog.h | 2 +
src/include/catalog/pg_namespace.dat | 3 +
src/include/catalog/pg_subscription.h | 11 +
src/include/commands/subscriptioncmds.h | 4 +
src/include/replication/conflict.h | 56 ++++
src/test/regress/expected/subscription.out | 336 +++++++++++++++------
src/test/regress/sql/subscription.sql | 118 ++++++++
src/tools/pgindent/typedefs.list | 2 +
16 files changed, 808 insertions(+), 106 deletions(-)
diff --git a/src/backend/catalog/catalog.c b/src/backend/catalog/catalog.c
index 7be49032934..d438dc682ec 100644
--- a/src/backend/catalog/catalog.c
+++ b/src/backend/catalog/catalog.c
@@ -86,7 +86,8 @@ bool
IsSystemClass(Oid relid, Form_pg_class reltuple)
{
/* IsCatalogRelationOid is a bit faster, so test that first */
- return (IsCatalogRelationOid(relid) || IsToastClass(reltuple));
+ return (IsCatalogRelationOid(relid) || IsToastClass(reltuple)
+ || IsConflictClass(reltuple));
}
/*
@@ -230,6 +231,18 @@ IsToastClass(Form_pg_class reltuple)
return IsToastNamespace(relnamespace);
}
+/*
+ * IsConflictClass - Check if the given pg_class tuple belongs to the conflict
+ * namespace.
+ */
+bool
+IsConflictClass(Form_pg_class reltuple)
+{
+ Oid relnamespace = reltuple->relnamespace;
+
+ return IsConflictNamespace(relnamespace);
+}
+
/*
* IsCatalogNamespace
* True iff namespace is pg_catalog.
@@ -264,6 +277,18 @@ IsToastNamespace(Oid namespaceId)
isTempToastNamespace(namespaceId);
}
+/*
+ * IsConflictNamespace
+ * True iff namespace is pg_conflict.
+ *
+ * Does not perform any catalog accesses.
+ */
+bool
+IsConflictNamespace(Oid namespaceId)
+{
+ return namespaceId == PG_CONFLICT_NAMESPACE;
+}
+
/*
* IsReservedName
diff --git a/src/backend/catalog/heap.c b/src/backend/catalog/heap.c
index 606434823cf..10dadf378a4 100644
--- a/src/backend/catalog/heap.c
+++ b/src/backend/catalog/heap.c
@@ -314,7 +314,8 @@ heap_create(const char *relname,
*/
if (!allow_system_table_mods &&
((IsCatalogNamespace(relnamespace) && relkind != RELKIND_INDEX) ||
- IsToastNamespace(relnamespace)) &&
+ IsToastNamespace(relnamespace) ||
+ IsConflictNamespace(relnamespace)) &&
IsNormalProcessingMode())
ereport(ERROR,
(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
diff --git a/src/backend/catalog/namespace.c b/src/backend/catalog/namespace.c
index c3b79a2ba48..cc7f0a045a6 100644
--- a/src/backend/catalog/namespace.c
+++ b/src/backend/catalog/namespace.c
@@ -3539,6 +3539,12 @@ CheckSetNamespace(Oid oldNspOid, Oid nspOid)
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("cannot move objects into or out of TOAST schema")));
+
+ /* similarly for CONFLICT schema */
+ if (nspOid == PG_CONFLICT_NAMESPACE || oldNspOid == PG_CONFLICT_NAMESPACE)
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot move objects into or out of CONFLICT schema")));
}
/*
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index 9a4791c573e..cb383a5ce04 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -31,6 +31,7 @@
#include "catalog/pg_publication_rel.h"
#include "catalog/pg_type.h"
#include "commands/publicationcmds.h"
+#include "commands/subscriptioncmds.h"
#include "funcapi.h"
#include "utils/array.h"
#include "utils/builtins.h"
@@ -85,6 +86,15 @@ check_publication_add_relation(Relation targetrel)
errmsg("cannot add relation \"%s\" to publication",
RelationGetRelationName(targetrel)),
errdetail("This operation is not supported for unlogged tables.")));
+
+ /* Can't be conflict log table */
+ if (IsConflictLogTable(RelationGetRelid(targetrel)))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot add relation \"%s.%s\" to publication",
+ get_namespace_name(RelationGetNamespace(targetrel)),
+ RelationGetRelationName(targetrel)),
+ errdetail("This operation is not supported for conflict log tables.")));
}
/*
@@ -145,6 +155,13 @@ is_publishable_class(Oid relid, Form_pg_class reltuple)
/*
* Another variant of is_publishable_class(), taking a Relation.
+ *
+ * Note: Conflict log tables are not publishable. However, we intentionally
+ * skip this check here because this function is called for every change and
+ * performing this check during every change publication is costly. To ensure
+ * unpublishable entries are ignored without incurring performance overhead,
+ * tuples inserted into the conflict log table uses the HEAP_INSERT_NO_LOGICAL
+ * flag. This allows the decoding layer to bypass these entries automatically.
*/
bool
is_publishable_relation(Relation rel)
@@ -169,7 +186,10 @@ pg_relation_is_publishable(PG_FUNCTION_ARGS)
tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
if (!HeapTupleIsValid(tuple))
PG_RETURN_NULL();
- result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple));
+
+ /* Subscription conflict log tables are not published */
+ result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple)) &&
+ !IsConflictLogTable(relid);
ReleaseSysCache(tuple);
PG_RETURN_BOOL(result);
}
@@ -890,7 +910,9 @@ GetAllPublicationRelations(char relkind, bool pubviaroot)
Form_pg_class relForm = (Form_pg_class) GETSTRUCT(tuple);
Oid relid = relForm->oid;
+ /* Subscription conflict log tables are not published */
if (is_publishable_class(relid, relForm) &&
+ !IsConflictLogTable(relid) &&
!(relForm->relispartition && pubviaroot))
result = lappend_oid(result, relid);
}
@@ -1018,7 +1040,7 @@ GetSchemaPublicationRelations(Oid schemaid, PublicationPartOpt pub_partopt)
Oid relid = relForm->oid;
char relkind;
- if (!is_publishable_class(relid, relForm))
+ if (!is_publishable_class(relid, relForm) || IsConflictLogTable(relid))
continue;
relkind = get_rel_relkind(relid);
diff --git a/src/backend/catalog/pg_subscription.c b/src/backend/catalog/pg_subscription.c
index 2b103245290..285a598497d 100644
--- a/src/backend/catalog/pg_subscription.c
+++ b/src/backend/catalog/pg_subscription.c
@@ -106,6 +106,7 @@ GetSubscription(Oid subid, bool missing_ok)
sub->retaindeadtuples = subform->subretaindeadtuples;
sub->maxretention = subform->submaxretention;
sub->retentionactive = subform->subretentionactive;
+ sub->conflictlogrelid = subform->subconflictlogrelid;
/* Get conninfo */
datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
@@ -141,6 +142,12 @@ GetSubscription(Oid subid, bool missing_ok)
Anum_pg_subscription_suborigin);
sub->origin = TextDatumGetCString(datum);
+ /* Get conflict log destination */
+ datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
+ tup,
+ Anum_pg_subscription_subconflictlogdest);
+ sub->conflictlogdest = TextDatumGetCString(datum);
+
/* Is the subscription owner a superuser? */
sub->ownersuperuser = superuser_arg(sub->owner);
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index 4ae3fb2c04a..ae6e2351e25 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -15,25 +15,31 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/htup_details.h"
#include "access/table.h"
#include "access/twophase.h"
#include "access/xact.h"
#include "catalog/catalog.h"
#include "catalog/dependency.h"
+#include "catalog/heap.h"
#include "catalog/indexing.h"
#include "catalog/namespace.h"
#include "catalog/objectaccess.h"
#include "catalog/objectaddress.h"
+#include "catalog/pg_am_d.h"
#include "catalog/pg_authid_d.h"
#include "catalog/pg_database_d.h"
+#include "catalog/pg_namespace.h"
#include "catalog/pg_subscription.h"
#include "catalog/pg_subscription_rel.h"
#include "catalog/pg_type.h"
+#include "commands/comment.h"
#include "commands/defrem.h"
#include "commands/event_trigger.h"
#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "miscadmin.h"
#include "nodes/makefuncs.h"
#include "pgstat.h"
@@ -51,6 +57,7 @@
#include "utils/lsyscache.h"
#include "utils/memutils.h"
#include "utils/pg_lsn.h"
+#include "utils/regproc.h"
#include "utils/syscache.h"
/*
@@ -75,6 +82,7 @@
#define SUBOPT_MAX_RETENTION_DURATION 0x00008000
#define SUBOPT_LSN 0x00010000
#define SUBOPT_ORIGIN 0x00020000
+#define SUBOPT_CONFLICT_LOG_DEST 0x00040000
/* check if the 'val' has 'bits' set */
#define IsSet(val, bits) (((val) & (bits)) == (bits))
@@ -103,6 +111,7 @@ typedef struct SubOpts
bool retaindeadtuples;
int32 maxretention;
char *origin;
+ ConflictLogDest logdest;
XLogRecPtr lsn;
} SubOpts;
@@ -135,7 +144,7 @@ static List *merge_publications(List *oldpublist, List *newpublist, bool addpub,
static void ReportSlotConnectionError(List *rstates, Oid subid, char *slotname, char *err);
static void CheckAlterSubOption(Subscription *sub, const char *option,
bool slot_needs_update, bool isTopLevel);
-
+static Oid create_conflict_log_table(Oid subid, char *subname);
/*
* Common option parsing function for CREATE and ALTER SUBSCRIPTION commands.
@@ -191,6 +200,8 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->maxretention = 0;
if (IsSet(supported_opts, SUBOPT_ORIGIN))
opts->origin = pstrdup(LOGICALREP_ORIGIN_ANY);
+ if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DEST))
+ opts->logdest = CONFLICT_LOG_DEST_LOG;
/* Parse options */
foreach(lc, stmt_options)
@@ -402,6 +413,20 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->specified_opts |= SUBOPT_LSN;
opts->lsn = lsn;
}
+ else if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DEST) &&
+ strcmp(defel->defname, "conflict_log_destination") == 0)
+ {
+ char *val;
+ ConflictLogDest dest;
+
+ if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_LOG_DEST))
+ errorConflictingDefElem(defel, pstate);
+
+ val = defGetString(defel);
+ dest = GetLogDestination(val);
+ opts->logdest = dest;
+ opts->specified_opts |= SUBOPT_CONFLICT_LOG_DEST;
+ }
else
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
@@ -612,7 +637,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
SUBOPT_DISABLE_ON_ERR | SUBOPT_PASSWORD_REQUIRED |
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
- SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN);
+ SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_DEST);
parse_subscription_options(pstate, stmt->options, supported_opts, &opts);
/*
@@ -747,6 +773,31 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
values[Anum_pg_subscription_suborigin - 1] =
CStringGetTextDatum(opts.origin);
+ /* Always set the destination, default will be 'log'. */
+ values[Anum_pg_subscription_subconflictlogdest - 1] =
+ CStringGetTextDatum(ConflictLogDestNames[opts.logdest]);
+
+ /*
+ * If logging to a table is required, physically create the logging
+ * relation and store its OID in the catalog.
+ */
+ if (opts.logdest == CONFLICT_LOG_DEST_TABLE ||
+ opts.logdest == CONFLICT_LOG_DEST_ALL)
+ {
+ Oid logrelid;
+
+ /* Store the Oid returned from creation. */
+ logrelid = create_conflict_log_table(subid, stmt->subname);
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(logrelid);
+ }
+ else
+ {
+ /* Destination is 'log'; no table is needed. */
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(InvalidOid);
+ }
+
tup = heap_form_tuple(RelationGetDescr(rel), values, nulls);
/* Insert tuple into catalog. */
@@ -1410,7 +1461,8 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
SUBOPT_MAX_RETENTION_DURATION |
- SUBOPT_ORIGIN);
+ SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_DEST);
parse_subscription_options(pstate, stmt->options,
supported_opts, &opts);
@@ -1665,6 +1717,63 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
origin = opts.origin;
}
+ if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_LOG_DEST))
+ {
+ ConflictLogDest old_dest =
+ GetLogDestination(sub->conflictlogdest);
+
+ if (opts.logdest != old_dest)
+ {
+ bool want_table =
+ IsSet(opts.logdest, CONFLICT_LOG_DEST_TABLE);
+ bool has_oldtable =
+ IsSet(old_dest, CONFLICT_LOG_DEST_TABLE);
+
+ values[Anum_pg_subscription_subconflictlogdest - 1] =
+ CStringGetTextDatum(ConflictLogDestNames[opts.logdest]);
+ replaces[Anum_pg_subscription_subconflictlogdest - 1] = true;
+
+ if (want_table && !has_oldtable)
+ {
+ Oid relid;
+
+ relid = create_conflict_log_table(subid, sub->name);
+
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(relid);
+ replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
+ true;
+ }
+ else if (!want_table && has_oldtable)
+ {
+ ObjectAddress object;
+
+ /*
+ * Conflict log tables are recorded as internal
+ * dependencies of the subscription. Drop the
+ * table if it is not required anymore to avoid
+ * stale or orphaned relations.
+ *
+ * XXX: At present, only conflict log tables are
+ * managed this way. In future if we introduce
+ * additional internal dependencies, we may need
+ * a targeted deletion to avoid deletion of any
+ * other objects.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId,
+ subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(InvalidOid);
+ replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
+ true;
+ }
+ }
+ }
+
update_tuple = true;
break;
}
@@ -2027,6 +2136,7 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
Form_pg_subscription form;
List *rstates;
bool must_use_password;
+ ObjectAddress object;
/*
* The launcher may concurrently start a new worker for this subscription.
@@ -2184,6 +2294,19 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
/* Clean up dependencies */
deleteSharedDependencyRecordsFor(SubscriptionRelationId, subid, 0);
+ /*
+ * Conflict log tables are recorded as internal dependencies of the
+ * subscription. We must drop the dependent objects before the
+ * subscription itself is removed. By using
+ * PERFORM_DELETION_SKIP_ORIGINAL, we ensure that only the conflict log
+ * table is reaped while the subscription remains for the final deletion
+ * step.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId, subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
/* Remove any associated relation synchronization states. */
RemoveSubscriptionRel(subid, InvalidOid);
@@ -3190,3 +3313,158 @@ defGetStreamingMode(DefElem *def)
def->defname)));
return LOGICALREP_STREAM_OFF; /* keep compiler quiet */
}
+
+/*
+ * Builds the TupleDesc for the conflict log table.
+ */
+static TupleDesc
+create_conflict_log_table_tupdesc(void)
+{
+ TupleDesc tupdesc;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_CONFLICT_ATTR_NUM);
+
+ for (int i = 0; i < MAX_CONFLICT_ATTR_NUM; i++)
+ {
+ Oid type_oid = ConflictLogSchema[i].atttypid;
+
+ /*
+ * Special handling for the JSON array type for proper
+ * TupleDescInitEntry call.
+ */
+ if (type_oid == JSONARRAYOID)
+ type_oid = get_array_type(JSONOID);
+
+ TupleDescInitEntry(tupdesc, i + 1,
+ ConflictLogSchema[i].attname,
+ type_oid,
+ -1, 0);
+ }
+
+ return BlessTupleDesc(tupdesc);
+}
+
+/*
+ * Create a structured conflict log table for a subscription.
+ *
+ * The table is created within the dedicated 'pg_conflict' namespace, which
+ * is system-managed. The table name is generated automatically using the
+ * subscription's OID (e.g., "pg_conflict_<subid>") to ensure uniqueness
+ * within the cluster and to avoid collisions during subscription renames.
+ */
+static Oid
+create_conflict_log_table(Oid subid, char *subname)
+{
+ TupleDesc tupdesc;
+ Oid relid;
+ ObjectAddress myself;
+ ObjectAddress subaddr;
+ char relname[NAMEDATALEN];
+
+ snprintf(relname, NAMEDATALEN, "pg_conflict_%u", subid);
+
+ /* There can not be an existing table with the same name. */
+ Assert(!OidIsValid(get_relname_relid(relname, PG_CONFLICT_NAMESPACE)));
+
+ /* Build the tuple descriptor for the new table. */
+ tupdesc = create_conflict_log_table_tupdesc();
+
+ /* Create conflict log table. */
+ relid = heap_create_with_catalog(relname,
+ PG_CONFLICT_NAMESPACE,
+ 0,
+ InvalidOid,
+ InvalidOid,
+ InvalidOid,
+ GetUserId(),
+ HEAP_TABLE_AM_OID,
+ tupdesc,
+ NIL,
+ RELKIND_RELATION,
+ RELPERSISTENCE_PERMANENT,
+ false,
+ false,
+ ONCOMMIT_NOOP,
+ (Datum) 0,
+ false,
+ true,
+ false,
+ InvalidOid,
+ NULL);
+
+ /*
+ * Establish an internal dependency between the conflict log table and
+ * the subscription. By using DEPENDENCY_INTERNAL, we ensure the table
+ * is automatically reaped when the subscription is dropped. This also
+ * prevents the table from being dropped independently unless the
+ * subscription itself is removed.
+ */
+ ObjectAddressSet(myself, RelationRelationId, relid);
+ ObjectAddressSet(subaddr, SubscriptionRelationId, subid);
+ recordDependencyOn(&myself, &subaddr, DEPENDENCY_INTERNAL);
+
+ /* Release tuple descriptor memory. */
+ FreeTupleDesc(tupdesc);
+
+ return relid;
+}
+
+/*
+ * GetLogDestination
+ *
+ * Convert string to enum by comparing against standardized labels.
+ */
+ConflictLogDest
+GetLogDestination(const char *dest)
+{
+ /* Empty string or NULL defaults to LOG. */
+ if (dest == NULL || dest[0] == '\0' || pg_strcasecmp(dest, "log") == 0)
+ return CONFLICT_LOG_DEST_LOG;
+
+ if (pg_strcasecmp(dest,
+ ConflictLogDestNames[CONFLICT_LOG_DEST_TABLE]) == 0)
+ return CONFLICT_LOG_DEST_TABLE;
+
+ if (pg_strcasecmp(dest, ConflictLogDestNames[CONFLICT_LOG_DEST_ALL]) == 0)
+ return CONFLICT_LOG_DEST_ALL;
+
+ /* Unrecognized string. */
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("unrecognized conflict_log_destination value: \"%s\"", dest),
+ errhint("Valid values are \"log\", \"table\", and \"all\".")));
+}
+
+/*
+ * Check if the specified relation is used as a conflict log table by any
+ * subscription.
+ */
+bool
+IsConflictLogTable(Oid relid)
+{
+ Relation rel;
+ TableScanDesc scan;
+ HeapTuple tup;
+ bool is_clt = false;
+
+ rel = table_open(SubscriptionRelationId, AccessShareLock);
+ scan = table_beginscan_catalog(rel, 0, NULL);
+
+ while (HeapTupleIsValid(tup = heap_getnext(scan, ForwardScanDirection)))
+ {
+ Form_pg_subscription subform = (Form_pg_subscription) GETSTRUCT(tup);
+
+ /* Direct Oid comparison from catalog */
+ if (OidIsValid(subform->subconflictlogrelid) &&
+ subform->subconflictlogrelid == relid)
+ {
+ is_clt = true;
+ break;
+ }
+ }
+
+ table_endscan(scan);
+ table_close(rel, AccessShareLock);
+
+ return is_clt;
+}
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index 3584c4e1428..20f08e548ba 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -6806,7 +6806,7 @@ describeSubscriptions(const char *pattern, bool verbose)
printQueryOpt myopt = pset.popt;
static const bool translate_columns[] = {false, false, false, false,
false, false, false, false, false, false, false, false, false, false,
- false, false, false, false};
+ false, false, false, false, false};
if (pset.sversion < 100000)
{
@@ -6900,15 +6900,22 @@ describeSubscriptions(const char *pattern, bool verbose)
appendPQExpBuffer(&buf,
", subskiplsn AS \"%s\"\n",
gettext_noop("Skip LSN"));
+
+ /* Conflict log destination is supported in v19 and higher */
+ if (pset.sversion >= 190000)
+ {
+ appendPQExpBuffer(&buf,
+ ", subconflictlogdest AS \"%s\"\n",
+ gettext_noop("Conflict log destination"));
+ }
}
/* Only display subscriptions in current database. */
- appendPQExpBufferStr(&buf,
- "FROM pg_catalog.pg_subscription\n"
- "WHERE subdbid = (SELECT oid\n"
- " FROM pg_catalog.pg_database\n"
- " WHERE datname = pg_catalog.current_database())");
-
+ appendPQExpBuffer(&buf,
+ "FROM pg_catalog.pg_subscription "
+ "WHERE subdbid = (SELECT oid\n"
+ " FROM pg_catalog.pg_database\n"
+ " WHERE datname = pg_catalog.current_database())");
if (!validateSQLNamePattern(&buf, pattern, true, false,
NULL, "subname", NULL,
NULL,
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index d81f2fcdbe6..e5eb434c90c 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -2344,8 +2344,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("(", "PUBLICATION");
/* ALTER SUBSCRIPTION <name> SET ( */
else if (Matches("ALTER", "SUBSCRIPTION", MatchAny, MatchAnyN, "SET", "("))
- COMPLETE_WITH("binary", "disable_on_error", "failover",
- "max_retention_duration", "origin",
+ COMPLETE_WITH("binary", "conflict_log_destination", "disable_on_error",
+ "failover", "max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
"synchronous_commit", "two_phase");
@@ -3851,8 +3851,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("WITH (");
/* Complete "CREATE SUBSCRIPTION <name> ... WITH ( <opt>" */
else if (Matches("CREATE", "SUBSCRIPTION", MatchAnyN, "WITH", "("))
- COMPLETE_WITH("binary", "connect", "copy_data", "create_slot",
- "disable_on_error", "enabled", "failover",
+ COMPLETE_WITH("binary", "conflict_log_destination", "connect", "copy_data",
+ "create_slot", "disable_on_error", "enabled", "failover",
"max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
diff --git a/src/include/catalog/catalog.h b/src/include/catalog/catalog.h
index a9d6e8ea986..8193229f2e2 100644
--- a/src/include/catalog/catalog.h
+++ b/src/include/catalog/catalog.h
@@ -25,6 +25,7 @@ extern bool IsInplaceUpdateRelation(Relation relation);
extern bool IsSystemClass(Oid relid, Form_pg_class reltuple);
extern bool IsToastClass(Form_pg_class reltuple);
+extern bool IsConflictClass(Form_pg_class reltuple);
extern bool IsCatalogRelationOid(Oid relid);
extern bool IsCatalogTextUniqueIndexOid(Oid relid);
@@ -32,6 +33,7 @@ extern bool IsInplaceUpdateOid(Oid relid);
extern bool IsCatalogNamespace(Oid namespaceId);
extern bool IsToastNamespace(Oid namespaceId);
+extern bool IsConflictNamespace(Oid namespaceId);
extern bool IsReservedName(const char *name);
diff --git a/src/include/catalog/pg_namespace.dat b/src/include/catalog/pg_namespace.dat
index 3075e142c73..c6e10150b21 100644
--- a/src/include/catalog/pg_namespace.dat
+++ b/src/include/catalog/pg_namespace.dat
@@ -18,6 +18,9 @@
{ oid => '99', oid_symbol => 'PG_TOAST_NAMESPACE',
descr => 'reserved schema for TOAST tables',
nspname => 'pg_toast', nspacl => '_null_' },
+{ oid => '1382', oid_symbol => 'PG_CONFLICT_NAMESPACE',
+ descr => 'reserved schema for subscription-specific conflict tables',
+ nspname => 'pg_conflict', nspacl => '_null_' },
# update dumpNamespace() if changing this descr
{ oid => '2200', oid_symbol => 'PG_PUBLIC_NAMESPACE',
descr => 'standard public schema',
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index f3571d2bfcf..4aa29ea15d4 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -90,6 +90,7 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
* exceeded max_retention_duration, when
* defined */
+ Oid subconflictlogrelid; /* Relid of the conflict log table. */
#ifdef CATALOG_VARLEN /* variable-length fields start here */
/* Connection string to the publisher */
text subconninfo BKI_FORCE_NOT_NULL;
@@ -103,6 +104,14 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
/* List of publications subscribed to */
text subpublications[1] BKI_FORCE_NOT_NULL;
+ /*
+ * Strategy for logging replication conflicts:
+ * 'log' - server log only,
+ * 'table' - internal table only,
+ * 'all' - both log and table.
+ */
+ text subconflictlogdest;
+
/* Only publish data originating from the specified origin */
text suborigin BKI_DEFAULT(LOGICALREP_ORIGIN_ANY);
#endif
@@ -152,12 +161,14 @@ typedef struct Subscription
* and the retention duration has not
* exceeded max_retention_duration, when
* defined */
+ Oid conflictlogrelid; /* conflict log table Oid */
char *conninfo; /* Connection string to the publisher */
char *slotname; /* Name of the replication slot */
char *synccommit; /* Synchronous commit setting for worker */
List *publications; /* List of publication names to subscribe to */
char *origin; /* Only publish data originating from the
* specified origin */
+ char *conflictlogdest; /* Conflict log destination */
} Subscription;
#ifdef EXPOSE_TO_CLIENT_CODE
diff --git a/src/include/commands/subscriptioncmds.h b/src/include/commands/subscriptioncmds.h
index 63504232a14..bc4a92af356 100644
--- a/src/include/commands/subscriptioncmds.h
+++ b/src/include/commands/subscriptioncmds.h
@@ -17,6 +17,7 @@
#include "catalog/objectaddress.h"
#include "parser/parse_node.h"
+#include "replication/conflict.h"
extern ObjectAddress CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bool isTopLevel);
@@ -36,4 +37,7 @@ extern void CheckSubDeadTupleRetention(bool check_guc, bool sub_disabled,
bool retention_active,
bool max_retention_set);
+extern ConflictLogDest GetLogDestination(const char *dest);
+extern bool IsConflictLogTable(Oid relid);
+
#endif /* SUBSCRIPTIONCMDS_H */
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index d538274637f..af6deaa4297 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -10,6 +10,7 @@
#define CONFLICT_H
#include "access/xlogdefs.h"
+#include "catalog/pg_type.h"
#include "nodes/pg_list.h"
#include "utils/timestamp.h"
@@ -79,6 +80,61 @@ typedef struct ConflictTupleInfo
* conflicting local row occurred */
} ConflictTupleInfo;
+/*
+ * Conflict log destination types.
+ *
+ * These values are defined as bitmask flags to allow for multiple simultaneous
+ * logging destinations (e.g., logging to both system logs and a table).
+ * Internally, we use these for bitwise comparisons (IsSet), but the string
+ * representation is stored in pg_subscription.subconflictlogdest.
+ */
+typedef enum ConflictLogDest
+{
+ /* Log conflicts to the server logs */
+ CONFLICT_LOG_DEST_LOG = 1 << 0, /* 0x01 */
+
+ /* Log conflicts to an internally managed table */
+ CONFLICT_LOG_DEST_TABLE = 1 << 1, /* 0x02 */
+
+ /* Convenience flag for all supported destinations */
+ CONFLICT_LOG_DEST_ALL = (CONFLICT_LOG_DEST_LOG | CONFLICT_LOG_DEST_TABLE)
+} ConflictLogDest;
+
+/*
+ * Array mapping for converting internal enum to string.
+ */
+static const char *const ConflictLogDestNames[] = {
+ [CONFLICT_LOG_DEST_LOG] = "log",
+ [CONFLICT_LOG_DEST_TABLE] = "table",
+ [CONFLICT_LOG_DEST_ALL] = "all"
+};
+
+/* Structure to hold metadata for one column of the conflict log table */
+typedef struct ConflictLogColumnDef
+{
+ const char *attname; /* Column name */
+ Oid atttypid; /* Data type OID */
+} ConflictLogColumnDef;
+
+/* The single source of truth for the conflict log table schema */
+static const ConflictLogColumnDef ConflictLogSchema[] =
+{
+ { .attname = "relid", .atttypid = OIDOID },
+ { .attname = "schemaname", .atttypid = TEXTOID },
+ { .attname = "relname", .atttypid = TEXTOID },
+ { .attname = "conflict_type", .atttypid = TEXTOID },
+ { .attname = "remote_xid", .atttypid = XIDOID },
+ { .attname = "remote_commit_lsn",.atttypid = LSNOID },
+ { .attname = "remote_commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "remote_origin", .atttypid = TEXTOID },
+ { .attname = "replica_identity", .atttypid = JSONOID },
+ { .attname = "remote_tuple", .atttypid = JSONOID },
+ { .attname = "local_conflicts", .atttypid = JSONARRAYOID }
+};
+
+/* Define the count using the array size */
+#define MAX_CONFLICT_ATTR_NUM (sizeof(ConflictLogSchema) / sizeof(ConflictLogSchema[0]))
+
extern bool GetTupleTransactionInfo(TupleTableSlot *localslot,
TransactionId *xmin,
RepOriginId *localorigin,
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index b3eccd8afe3..d5f8abe9325 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -116,18 +116,18 @@ CREATE SUBSCRIPTION regress_testsub4 CONNECTION 'dbname=regress_doesnotexist' PU
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub4 SET (origin = any);
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub3;
@@ -145,10 +145,10 @@ ALTER SUBSCRIPTION regress_testsub CONNECTION 'foobar';
ERROR: invalid connection string syntax: missing "=" after "foobar" in connection info string
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET PUBLICATION testpub2, testpub3 WITH (refresh = false);
@@ -157,10 +157,10 @@ ALTER SUBSCRIPTION regress_testsub SET (slot_name = 'newname');
ALTER SUBSCRIPTION regress_testsub SET (password_required = false);
ALTER SUBSCRIPTION regress_testsub SET (run_as_owner = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (password_required = true);
@@ -176,10 +176,10 @@ ERROR: unrecognized subscription parameter: "create_slot"
-- ok
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/12345');
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345 | log
(1 row)
-- ok - with lsn = NONE
@@ -188,10 +188,10 @@ ALTER SUBSCRIPTION regress_testsub SKIP (lsn = NONE);
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/0');
ERROR: invalid WAL location (LSN): 0/0
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
BEGIN;
@@ -223,10 +223,10 @@ ALTER SUBSCRIPTION regress_testsub_foo SET (synchronous_commit = foobar);
ERROR: invalid value for parameter "synchronous_commit": "foobar"
HINT: Available values: local, remote_write, remote_apply, on, off.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
----------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+---------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
-- rename back to keep the rest simple
@@ -255,19 +255,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (binary = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -279,27 +279,27 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = parallel);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- fail - publication already exists
@@ -314,10 +314,10 @@ ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refr
ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refresh = false);
ERROR: publication "testpub1" is already in subscription "regress_testsub"
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- fail - publication used more than once
@@ -332,10 +332,10 @@ ERROR: publication "testpub3" is not in subscription "regress_testsub"
-- ok - delete publications
ALTER SUBSCRIPTION regress_testsub DROP PUBLICATION testpub1, testpub2 WITH (refresh = false);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -371,19 +371,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- we can alter streaming when two_phase enabled
ALTER SUBSCRIPTION regress_testsub SET (streaming = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -393,10 +393,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -409,18 +409,18 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (disable_on_error = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -433,10 +433,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -450,19 +450,19 @@ NOTICE: max_retention_duration is ineffective when retain_dead_tuples is disabl
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- ok
ALTER SUBSCRIPTION regress_testsub SET (max_retention_duration = 0);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -517,7 +517,167 @@ COMMIT;
-- ok, owning it is enough for this stuff
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG DESTINATION TESTS
+--
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+-- fail - unrecognized parameter value
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
+ERROR: unrecognized conflict_log_destination value: "invalid"
+HINT: Valid values are "log", "table", and "all".
+-- verify subconflictlogdest is 'log' and relid is 0 (InvalidOid) for default case
+CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
+ subname | subconflictlogdest | subconflictlogrelid
+------------------------------+--------------------+---------------------
+ regress_conflict_log_default | log | 0
+(1 row)
+
+-- verify empty string defaults to 'log'
+CREATE SUBSCRIPTION regress_conflict_empty_str CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = '');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
+ subname | subconflictlogdest | subconflictlogrelid
+----------------------------+--------------------+---------------------
+ regress_conflict_empty_str | log | 0
+(1 row)
+
+-- this should generate an internal table named pg_conflict_$subid$
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- check metadata in pg_subscription: destination should be 'table' and relid valid
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname | subconflictlogdest | has_relid
+------------------------+--------------------+-----------
+ regress_conflict_test1 | table | t
+(1 row)
+
+-- verify the physical table exists and its OID matches subconflictlogrelid
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND c.oid = s.subconflictlogrelid;
+ count
+-------
+ 1
+(1 row)
+
+-- check if the internal table has the correct schema (11 columns)
+SELECT count(*)
+FROM pg_attribute a
+JOIN pg_class c ON a.attrelid = c.oid
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0;
+ count
+-------
+ 11
+(1 row)
+
+--
+-- ALTER SUBSCRIPTION - conflict_log_destination state transitions
+--
+-- These tests verify the transition logic between different logging
+-- destinations, ensuring internal tables are created or dropped as expected.
+--
+-- transition from 'log' to 'all'
+-- a new internal conflict log table should be created
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
+-- verify metadata after ALTER (destination should be 'all')
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogdest | has_relid
+------------------------+--------------------+-----------
+ regress_conflict_test2 | all | t
+(1 row)
+
+-- transition from 'all' to 'table'
+-- should NOT drop the table, only change destination string
+SELECT subconflictlogrelid AS old_relid FROM pg_subscription WHERE subname = 'regress_conflict_test2' \gset
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'table');
+SELECT subconflictlogdest, subconflictlogrelid = :old_relid AS relid_unchanged
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subconflictlogdest | relid_unchanged
+--------------------+-----------------
+ table | t
+(1 row)
+
+-- transition from 'table' to 'log'
+-- should drop the table and clear relid
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log');
+SELECT subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subconflictlogdest | subconflictlogrelid
+--------------------+---------------------
+ log | 0
+(1 row)
+
+-- verify the physical table is gone
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test2';
+ count
+-------
+ 0
+(1 row)
+
+-- ensure drop table not allowed and DROP SUBSCRIPTION reaps the table
+-- re-enable table logging for verification
+ALTER SUBSCRIPTION regress_conflict_test1 SET (conflict_log_destination = 'table');
+-- fail - drop table not allowed due to internal dependency
+-- use DO block to hide OID in error message
+DO $$
+BEGIN
+ EXECUTE 'DROP TABLE ' || (SELECT 'pg_conflict.pg_conflict_' || oid FROM pg_subscription WHERE subname = 'regress_conflict_test1');
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege';
+END $$;
+NOTICE: captured expected error: insufficient_privilege
+-- PUBLICATION: Verify internal tables are not publishable
+-- pg_relation_is_publishable should return false for internal conflict log tables
+SELECT pg_relation_is_publishable(subconflictlogrelid)
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ pg_relation_is_publishable
+----------------------------
+ f
+(1 row)
+
+-- CLEANUP: Proper drop reaps the table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+-- Verify the table OID for reap check
+SELECT 'pg_conflict_' || oid AS internal_tablename FROM pg_subscription WHERE subname = 'regress_conflict_test1' \gset
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the internal table was reaped via dependency
+SELECT to_regclass(:'internal_tablename');
+ to_regclass
+-------------
+
+(1 row)
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_log_default DISABLE;
+ALTER SUBSCRIPTION regress_conflict_log_default SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_log_default;
+ALTER SUBSCRIPTION regress_conflict_empty_str DISABLE;
+ALTER SUBSCRIPTION regress_conflict_empty_str SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_empty_str;
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
RESET SESSION AUTHORIZATION;
+DROP SCHEMA clt;
+ERROR: schema "clt" does not exist
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
DROP ROLE regress_subscription_user3;
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index ef0c298d2df..6c7f358ffd2 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -365,7 +365,125 @@ COMMIT;
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG DESTINATION TESTS
+--
+
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+
+-- fail - unrecognized parameter value
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
+
+-- verify subconflictlogdest is 'log' and relid is 0 (InvalidOid) for default case
+CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
+
+-- verify empty string defaults to 'log'
+CREATE SUBSCRIPTION regress_conflict_empty_str CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = '');
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
+
+-- this should generate an internal table named pg_conflict_$subid$
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+
+-- check metadata in pg_subscription: destination should be 'table' and relid valid
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- verify the physical table exists and its OID matches subconflictlogrelid
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND c.oid = s.subconflictlogrelid;
+
+-- check if the internal table has the correct schema (11 columns)
+SELECT count(*)
+FROM pg_attribute a
+JOIN pg_class c ON a.attrelid = c.oid
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0;
+
+--
+-- ALTER SUBSCRIPTION - conflict_log_destination state transitions
+--
+-- These tests verify the transition logic between different logging
+-- destinations, ensuring internal tables are created or dropped as expected.
+--
+
+-- transition from 'log' to 'all'
+-- a new internal conflict log table should be created
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
+
+-- verify metadata after ALTER (destination should be 'all')
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'all' to 'table'
+-- should NOT drop the table, only change destination string
+SELECT subconflictlogrelid AS old_relid FROM pg_subscription WHERE subname = 'regress_conflict_test2' \gset
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'table');
+SELECT subconflictlogdest, subconflictlogrelid = :old_relid AS relid_unchanged
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'table' to 'log'
+-- should drop the table and clear relid
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log');
+SELECT subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- verify the physical table is gone
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test2';
+
+-- ensure drop table not allowed and DROP SUBSCRIPTION reaps the table
+-- re-enable table logging for verification
+ALTER SUBSCRIPTION regress_conflict_test1 SET (conflict_log_destination = 'table');
+
+-- fail - drop table not allowed due to internal dependency
+-- use DO block to hide OID in error message
+DO $$
+BEGIN
+ EXECUTE 'DROP TABLE ' || (SELECT 'pg_conflict.pg_conflict_' || oid FROM pg_subscription WHERE subname = 'regress_conflict_test1');
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege';
+END $$;
+
+-- PUBLICATION: Verify internal tables are not publishable
+-- pg_relation_is_publishable should return false for internal conflict log tables
+SELECT pg_relation_is_publishable(subconflictlogrelid)
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- CLEANUP: Proper drop reaps the table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+
+-- Verify the table OID for reap check
+SELECT 'pg_conflict_' || oid AS internal_tablename FROM pg_subscription WHERE subname = 'regress_conflict_test1' \gset
+
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the internal table was reaped via dependency
+SELECT to_regclass(:'internal_tablename');
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_log_default DISABLE;
+ALTER SUBSCRIPTION regress_conflict_log_default SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_log_default;
+
+ALTER SUBSCRIPTION regress_conflict_empty_str DISABLE;
+ALTER SUBSCRIPTION regress_conflict_empty_str SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_empty_str;
+
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+
RESET SESSION AUTHORIZATION;
+DROP SCHEMA clt;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
DROP ROLE regress_subscription_user3;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index b9e671fcda8..7e2410bf54e 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -500,6 +500,8 @@ ConditionVariableMinimallyPadded
ConditionalStack
ConfigData
ConfigVariable
+ConflictLogColumnDef
+ConflictLogDest
ConflictTupleInfo
ConflictType
ConnCacheEntry
--
2.49.0
[application/octet-stream] v18-0002-Implement-the-conflict-insertion-infrastructure-.patch (33.0K, ../../CAFiTN-t-TAn9NhfVCC7KMNN=SW5x0o2QwjSc9VzieBixLm9CMA@mail.gmail.com/4-v18-0002-Implement-the-conflict-insertion-infrastructure-.patch)
download | inline diff:
From 512d13c7a0657f020038dd4b57d071ac4e087b02 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Sat, 20 Dec 2025 15:20:09 +0530
Subject: [PATCH v18 2/3] Implement the conflict insertion infrastructure for
the conflict log table
This patch introduces the core logic to populate the conflict log table whenever
a logical replication conflict is detected. It captures the remote transaction
details along with the corresponding local state at the time of the conflict.
Handling Multi-row Conflicts: A single remote tuple may conflict with multiple
local tuples (e.g., in the case of multiple_unique_conflicts). To handle this,
the infrastructure creates a single row in the conflict log table for each
remote tuple. The details of all conflicting local rows are aggregated into a
single JSON array in the local_conflicts column.
The JSON array uses the following structured format:
[ { "xid": "1001", "commit_ts": "2025-12-25 10:00:00+05:30", "origin": "node_1",
"key": {"id": 1}, "tuple": {"id": 1, "val": "old_data"} }, ... ]
Example of querying the structured conflict data:
SELECT remote_xid, relname, remote_origin, local_conflicts[1] ->> 'xid' AS local_xid,
local_conflicts[1] ->> 'tuple' AS local_tuple
FROM myschema.conflict_log_history2;
remote_xid | relname | remote_origin | local_xid | local_tuple
------------+----------+---------------+-----------+---------------------
760 | test | pg_16406 | 771 | {"a":1,"b":10}
765 | conf_tab | pg_16406 | 775 | {"a":2,"b":2,"c":2}
---
src/backend/replication/logical/conflict.c | 548 +++++++++++++++++--
src/backend/replication/logical/launcher.c | 1 +
src/backend/replication/logical/worker.c | 31 +-
src/include/replication/conflict.h | 4 +-
src/include/replication/worker_internal.h | 7 +
src/test/subscription/t/037_conflict_dest.pl | 181 ++++++
6 files changed, 728 insertions(+), 44 deletions(-)
create mode 100644 src/test/subscription/t/037_conflict_dest.pl
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 93222ee3b88..e23ff0b70cf 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -15,13 +15,20 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/tableam.h"
+#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "pgstat.h"
#include "replication/conflict.h"
#include "replication/worker_internal.h"
#include "storage/lmgr.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/lsyscache.h"
+#include "utils/pg_lsn.h"
+#include "utils/jsonb.h"
static const char *const ConflictTypeNames[] = {
[CT_INSERT_EXISTS] = "insert_exists",
@@ -34,6 +41,19 @@ static const char *const ConflictTypeNames[] = {
[CT_MULTIPLE_UNIQUE_CONFLICTS] = "multiple_unique_conflicts"
};
+/* Schema for the elements within the 'local_conflicts' JSON array */
+static const ConflictLogColumnDef LocalConflictSchema[] =
+{
+ { .attname = "xid", .atttypid = XIDOID },
+ { .attname = "commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "origin", .atttypid = TEXTOID },
+ { .attname = "key", .atttypid = JSONOID },
+ { .attname = "tuple", .atttypid = JSONOID }
+};
+
+#define MAX_LOCAL_CONFLICT_INFO_ATTRS \
+ (sizeof(LocalConflictSchema) / sizeof(LocalConflictSchema[0]))
+
static int errcode_apply_conflict(ConflictType type);
static void errdetail_apply_conflict(EState *estate,
ResultRelInfo *relinfo,
@@ -50,8 +70,27 @@ static char *build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *localslot,
TupleTableSlot *remoteslot,
Oid indexoid);
+static void build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull);
static char *build_index_value_desc(EState *estate, Relation localrel,
TupleTableSlot *slot, Oid indexoid);
+static Datum tuple_table_slot_to_json_datum(TupleTableSlot *slot);
+static Datum tuple_table_slot_to_indextup_json(EState *estate,
+ Relation localrel,
+ Oid replica_index,
+ TupleTableSlot *slot);
+static TupleDesc build_conflict_tupledesc(void);
+static Datum build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples);
+static void prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot);
/*
* Get the xmin and commit timestamp data (origin and timestamp) associated
@@ -105,30 +144,83 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
ConflictType type, TupleTableSlot *searchslot,
TupleTableSlot *remoteslot, List *conflicttuples)
{
- Relation localrel = relinfo->ri_RelationDesc;
- StringInfoData err_detail;
+ Relation localrel = relinfo->ri_RelationDesc;
+ ConflictLogDest dest;
+ Relation conflictlogrel;
- initStringInfo(&err_detail);
+ /*
+ * Get both the conflict log destination and the opened conflict log
+ * relation for insertion.
+ */
+ conflictlogrel = GetConflictLogTableInfo(&dest);
- /* Form errdetail message by combining conflicting tuples information. */
- foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
- errdetail_apply_conflict(estate, relinfo, type, searchslot,
- conflicttuple->slot, remoteslot,
- conflicttuple->indexoid,
- conflicttuple->xmin,
- conflicttuple->origin,
- conflicttuple->ts,
- &err_detail);
+ /* Insert to table if destination is 'table' or 'all' */
+ if ((dest & CONFLICT_LOG_DEST_TABLE) != 0)
+ {
+ Assert(conflictlogrel != NULL);
+
+ /*
+ * Prepare the conflict log tuple. If the error level is below ERROR,
+ * insert it immediately. Otherwise, defer the insertion to a new
+ * transaction after the current one aborts, ensuring the insertion of
+ * the log tuple is not rolled back.
+ */
+ prepare_conflict_log_tuple(estate,
+ relinfo->ri_RelationDesc,
+ conflictlogrel,
+ type,
+ searchslot,
+ conflicttuples,
+ remoteslot);
+ if (elevel < ERROR)
+ InsertConflictLogTuple(conflictlogrel);
+
+ table_close(conflictlogrel, RowExclusiveLock);
+ }
pgstat_report_subscription_conflict(MySubscription->oid, type);
- ereport(elevel,
- errcode_apply_conflict(type),
- errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
- get_namespace_name(RelationGetNamespace(localrel)),
- RelationGetRelationName(localrel),
- ConflictTypeNames[type]),
- errdetail_internal("%s", err_detail.data));
+ /* Decide what detail to show in server logs. */
+ if ((dest & CONFLICT_LOG_DEST_LOG) != 0)
+ {
+ StringInfoData err_detail;
+
+ initStringInfo(&err_detail);
+
+ /* Form errdetail message by combining conflicting tuples information. */
+ foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
+ errdetail_apply_conflict(estate, relinfo, type, searchslot,
+ conflicttuple->slot, remoteslot,
+ conflicttuple->indexoid,
+ conflicttuple->xmin,
+ conflicttuple->origin,
+ conflicttuple->ts,
+ &err_detail);
+
+ /* Standard reporting with full internal details. */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail_internal("%s", err_detail.data));
+ }
+ else
+ {
+ /*
+ * 'table' only: Report the error msg but omit raw tuple data from
+ * server logs since it's already captured in the internal table.
+ */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail("Conflict details logged to internal table with OID %u.",
+ MySubscription->conflictlogrelid));
+ }
}
/*
@@ -162,6 +254,67 @@ InitConflictIndexes(ResultRelInfo *relInfo)
relInfo->ri_onConflictArbiterIndexes = uniqueIndexes;
}
+/*
+ * GetConflictLogTableInfo
+ *
+ * Fetches conflict logging metadata from the cached MySubscription pointer.
+ * Sets the destination enum in *log_dest and, if applicable, opens and
+ * returns the relation handle for the internal log table.
+ */
+Relation
+GetConflictLogTableInfo(ConflictLogDest *log_dest)
+{
+ Oid conflictlogrelid;
+ Relation conflictlogrel = NULL;
+
+ /*
+ * Convert the text log destination to the internal enum. MySubscription
+ * already contains the data from pg_subscription.
+ */
+ *log_dest = GetLogDestination(MySubscription->conflictlogdest);
+ conflictlogrelid = MySubscription->conflictlogrelid;
+
+ /* If destination is 'log' only, no table to open. */
+ if (*log_dest == CONFLICT_LOG_DEST_LOG)
+ return NULL;
+
+ Assert(OidIsValid(conflictlogrelid));
+
+ conflictlogrel = table_open(conflictlogrelid, RowExclusiveLock);
+
+ /* Conflict log table is dropped or not accessible. */
+ if (conflictlogrel == NULL)
+ ereport(WARNING,
+ (errcode(ERRCODE_UNDEFINED_TABLE),
+ errmsg("conflict log table with OID %u does not exist",
+ conflictlogrelid)));
+
+ return conflictlogrel;
+}
+
+/*
+ * InsertConflictLogTuple
+ *
+ * Insert conflict log tuple into the conflict log table. It uses
+ * HEAP_INSERT_NO_LOGICAL to explicitly block logical decoding of the tuple
+ * inserted into the conflict log table.
+ */
+void
+InsertConflictLogTuple(Relation conflictlogrel)
+{
+ int options = HEAP_INSERT_NO_LOGICAL;
+
+ /* A valid tuple must be prepared and stored in MyLogicalRepWorker. */
+ Assert(MyLogicalRepWorker->conflict_log_tuple != NULL);
+
+ heap_insert(conflictlogrel, MyLogicalRepWorker->conflict_log_tuple,
+ GetCurrentCommandId(true), options, NULL);
+
+ /* Free conflict log tuple. */
+ heap_freetuple(MyLogicalRepWorker->conflict_log_tuple);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+}
+
/*
* Add SQLSTATE error code to the current conflict report.
*/
@@ -472,6 +625,40 @@ build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
return tuple_value.data;
}
+/*
+ * Helper function to extract the "raw" index key Datums and their null flags
+ * from a TupleTableSlot, given an already open index descriptor.
+ * This is the reusable core logic.
+ */
+static void
+build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull)
+{
+ TupleTableSlot *tableslot = slot;
+
+ /*
+ * If the slot is a virtual slot, copy it into a heap tuple slot as
+ * FormIndexDatum only works with heap tuple slots.
+ */
+ if (TTS_IS_VIRTUAL(slot))
+ {
+ /* Slot is created within the EState's tuple table */
+ tableslot = table_slot_create(localrel, &estate->es_tupleTable);
+ tableslot = ExecCopySlot(tableslot, slot);
+ }
+
+ /*
+ * Initialize ecxt_scantuple for potential use in FormIndexDatum
+ */
+ GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+
+ /* Form the index datums */
+ FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values,
+ isnull);
+}
+
/*
* Helper functions to construct a string describing the contents of an index
* entry. See BuildIndexValueDescription for details.
@@ -487,41 +674,318 @@ build_index_value_desc(EState *estate, Relation localrel, TupleTableSlot *slot,
Relation indexDesc;
Datum values[INDEX_MAX_KEYS];
bool isnull[INDEX_MAX_KEYS];
- TupleTableSlot *tableslot = slot;
- if (!tableslot)
+ if (!slot)
return NULL;
Assert(CheckRelationOidLockedByMe(indexoid, RowExclusiveLock, true));
indexDesc = index_open(indexoid, NoLock);
- /*
- * If the slot is a virtual slot, copy it into a heap tuple slot as
- * FormIndexDatum only works with heap tuple slots.
- */
- if (TTS_IS_VIRTUAL(slot))
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+
+ index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+
+ index_close(indexDesc, NoLock);
+
+ return index_value;
+}
+
+/*
+ * tuple_table_slot_to_json_datum
+ *
+ * Helper function to convert a TupleTableSlot to Jsonb.
+ */
+static Datum
+tuple_table_slot_to_json_datum(TupleTableSlot *slot)
+{
+ HeapTuple tuple;
+ Datum datum;
+ Datum json;
+
+ Assert(slot != NULL);
+
+ tuple = ExecCopySlotHeapTuple(slot);
+ datum = heap_copy_tuple_as_datum(tuple, slot->tts_tupleDescriptor);
+
+ json = DirectFunctionCall1(row_to_json, datum);
+ heap_freetuple(tuple);
+
+ return json;
+}
+
+/*
+ * tuple_table_slot_to_indextup_json
+ *
+ * Fetch replica identity key from the tuple table slot and convert into a
+ * jsonb datum.
+ */
+static Datum
+tuple_table_slot_to_indextup_json(EState *estate, Relation localrel,
+ Oid indexid, TupleTableSlot *slot)
+{
+ Relation indexDesc;
+ Datum values[INDEX_MAX_KEYS];
+ bool isnull[INDEX_MAX_KEYS];
+ HeapTuple tuple;
+ TupleDesc tupdesc;
+ Datum datum;
+
+ Assert(slot != NULL);
+
+ Assert(CheckRelationOidLockedByMe(indexid, RowExclusiveLock, true));
+
+ indexDesc = index_open(indexid, NoLock);
+
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+ tupdesc = RelationGetDescr(indexDesc);
+
+ /* Bless the tupdesc so it can be looked up by row_to_json. */
+ BlessTupleDesc(tupdesc);
+
+ /* Form the replica identity tuple. */
+ tuple = heap_form_tuple(tupdesc, values, isnull);
+ datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ index_close(indexDesc, NoLock);
+
+ /* Convert to a JSONB datum. */
+ return DirectFunctionCall1(row_to_json, datum);
+}
+
+static TupleDesc
+build_conflict_tupledesc(void)
+{
+ TupleDesc tupdesc;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ for (int i = 0; i < MAX_LOCAL_CONFLICT_INFO_ATTRS; i++)
+ TupleDescInitEntry(tupdesc, (AttrNumber) (i + 1),
+ LocalConflictSchema[i].attname,
+ LocalConflictSchema[i].atttypid,
+ -1, 0);
+
+ BlessTupleDesc(tupdesc);
+
+ return tupdesc;
+}
+
+/*
+ * Builds the local conflicts JSONB array column from the list of
+ * ConflictTupleInfo objects.
+ *
+ * Example output structure:
+ * [ { "xid": "1001", "commit_ts": "...", "origin": "...", "tuple": {...} }, ... ]
+ */
+static Datum
+build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples)
+{
+ ListCell *lc;
+ List *json_datums = NIL; /* List to hold the row_to_json results (type json) */
+ Datum *json_datum_array;
+ bool *json_null_array;
+ Datum json_array_datum;
+ int num_conflicts;
+ int i;
+ int16 typlen;
+ bool typbyval;
+ char typalign;
+ TupleDesc tupdesc;
+
+ /* Build local conflicts tuple descriptor. */
+ tupdesc = build_conflict_tupledesc();
+
+ /* Process local conflict tuple list and prepare an array of JSON. */
+ foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
{
- tableslot = table_slot_create(localrel, &estate->es_tupleTable);
- tableslot = ExecCopySlot(tableslot, slot);
+ Datum values[MAX_LOCAL_CONFLICT_INFO_ATTRS] = {0};
+ bool nulls[MAX_LOCAL_CONFLICT_INFO_ATTRS] = {0};
+ char *origin_name = NULL;
+ HeapTuple tuple;
+ Datum json_datum;
+ int attno;
+
+ attno = 0;
+ values[attno++] = TransactionIdGetDatum(conflicttuple->xmin);
+
+ if (conflicttuple->ts)
+ values[attno++] = TimestampTzGetDatum(conflicttuple->ts);
+ else
+ nulls[attno++] = true;
+
+ if (conflicttuple->origin != InvalidRepOriginId)
+ replorigin_by_oid(conflicttuple->origin, true, &origin_name);
+
+ /* Store empty string if origin name for the tuple is NULL. */
+ if (origin_name != NULL)
+ values[attno++] = CStringGetTextDatum(origin_name);
+ else
+ nulls[attno++] = true;
+
+ /*
+ * Add the conflicting key values in the case of a unique constraint
+ * violation.
+ */
+ if (conflict_type == CT_INSERT_EXISTS ||
+ conflict_type == CT_UPDATE_EXISTS ||
+ conflict_type == CT_MULTIPLE_UNIQUE_CONFLICTS)
+ {
+ Oid indexoid = conflicttuple->indexoid;
+
+ Assert(OidIsValid(indexoid) && conflicttuple->slot &&
+ CheckRelationOidLockedByMe(indexoid, RowExclusiveLock,
+ true));
+ values[attno++] =
+ tuple_table_slot_to_indextup_json(estate, rel,
+ indexoid,
+ conflicttuple->slot);
+ }
+ else
+ nulls[attno++] = true;
+
+ /* Convert conflicting tuple to JSON datum. */
+ if (conflicttuple->slot)
+ values[attno] = tuple_table_slot_to_json_datum(conflicttuple->slot);
+ else
+ nulls[attno] = true;
+
+ Assert(attno + 1 == MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ tuple = heap_form_tuple(tupdesc, values, nulls);
+
+ json_datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ /*
+ * Build the higher level JSON datum in format described in function
+ * header.
+ */
+ json_datum = DirectFunctionCall1(row_to_json, json_datum);
+
+ /* Done with the temporary tuple. */
+ heap_freetuple(tuple);
+
+ /* Add to the array element. */
+ json_datums = lappend(json_datums, (void *) json_datum);
}
- /*
- * Initialize ecxt_scantuple for potential use in FormIndexDatum when
- * index expressions are present.
- */
- GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+ num_conflicts = list_length(json_datums);
- /*
- * The values/nulls arrays passed to BuildIndexValueDescription should be
- * the results of FormIndexDatum, which are the "raw" input to the index
- * AM.
- */
- FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values, isnull);
+ json_datum_array = (Datum *) palloc(num_conflicts * sizeof(Datum));
+ json_null_array = (bool *) palloc0(num_conflicts * sizeof(bool));
- index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+ i = 0;
+ foreach(lc, json_datums)
+ {
+ json_datum_array[i] = (Datum) lfirst(lc);
+ i++;
+ }
- index_close(indexDesc, NoLock);
+ /* Construct the json[] array Datum. */
+ get_typlenbyvalalign(JSONOID, &typlen, &typbyval, &typalign);
+ json_array_datum = PointerGetDatum(construct_array(json_datum_array,
+ num_conflicts,
+ JSONOID,
+ typlen,
+ typbyval,
+ typalign));
+ pfree(json_datum_array);
+ pfree(json_null_array);
+
+ return json_array_datum;
+}
- return index_value;
+/*
+ * prepare_conflict_log_tuple
+ *
+ * This routine prepares a tuple detailing a conflict encountered during
+ * logical replication. The prepared tuple will be stored in
+ * MyLogicalRepWorker->conflict_log_tuple which should be inserted into the
+ * conflict log table by calling InsertConflictLogTuple.
+ */
+static void
+prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot)
+{
+ Datum values[MAX_CONFLICT_ATTR_NUM] = {0};
+ bool nulls[MAX_CONFLICT_ATTR_NUM] = {0};
+ int attno;
+ char *remote_origin = NULL;
+ MemoryContext oldctx;
+
+ Assert(MyLogicalRepWorker->conflict_log_tuple == NULL);
+
+ /* Populate the values and nulls arrays. */
+ attno = 0;
+ values[attno++] = ObjectIdGetDatum(RelationGetRelid(rel));
+
+ values[attno++] =
+ CStringGetTextDatum(get_namespace_name(RelationGetNamespace(rel)));
+
+ values[attno++] = CStringGetTextDatum(RelationGetRelationName(rel));
+
+ values[attno++] = CStringGetTextDatum(ConflictTypeNames[conflict_type]);
+
+ if (TransactionIdIsValid(remote_xid))
+ values[attno++] = TransactionIdGetDatum(remote_xid);
+ else
+ nulls[attno++] = true;
+
+ values[attno++] = LSNGetDatum(remote_final_lsn);
+
+ if (remote_commit_ts > 0)
+ values[attno++] = TimestampTzGetDatum(remote_commit_ts);
+ else
+ nulls[attno++] = true;
+
+ if (replorigin_session_origin != InvalidRepOriginId)
+ replorigin_by_oid(replorigin_session_origin, true, &remote_origin);
+
+ if (remote_origin != NULL)
+ values[attno++] = CStringGetTextDatum(remote_origin);
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(searchslot))
+ {
+ Oid replica_index = GetRelationIdentityOrPK(rel);
+
+ /*
+ * If the table has a valid replica identity index, build the index
+ * json datum from key value. Otherwise, construct it from the complete
+ * tuple in REPLICA IDENTITY FULL cases.
+ */
+ if (OidIsValid(replica_index))
+ values[attno++] = tuple_table_slot_to_indextup_json(estate, rel,
+ replica_index,
+ searchslot);
+ else
+ values[attno++] = tuple_table_slot_to_json_datum(searchslot);
+ }
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(remoteslot))
+ values[attno++] = tuple_table_slot_to_json_datum(remoteslot);
+ else
+ nulls[attno++] = true;
+
+ values[attno] = build_local_conflicts_json_array(estate, rel,
+ conflict_type,
+ conflicttuples);
+
+ Assert(attno + 1 == MAX_CONFLICT_ATTR_NUM);
+
+ oldctx = MemoryContextSwitchTo(ApplyContext);
+ MyLogicalRepWorker->conflict_log_tuple =
+ heap_form_tuple(RelationGetDescr(conflictlogrel), values, nulls);
+ MemoryContextSwitchTo(oldctx);
}
diff --git a/src/backend/replication/logical/launcher.c b/src/backend/replication/logical/launcher.c
index 3ed86480be2..2dda5a44218 100644
--- a/src/backend/replication/logical/launcher.c
+++ b/src/backend/replication/logical/launcher.c
@@ -477,6 +477,7 @@ retry:
worker->oldest_nonremovable_xid = retain_dead_tuples
? MyReplicationSlot->data.xmin
: InvalidTransactionId;
+ worker->conflict_log_tuple = NULL;
worker->last_lsn = InvalidXLogRecPtr;
TIMESTAMP_NOBEGIN(worker->last_send_time);
TIMESTAMP_NOBEGIN(worker->last_recv_time);
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index ad281e7069b..5ac826c279f 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -482,7 +482,9 @@ static bool MySubscriptionValid = false;
static List *on_commit_wakeup_workers_subids = NIL;
bool in_remote_transaction = false;
-static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+TransactionId remote_xid = InvalidTransactionId;
+TimestampTz remote_commit_ts = 0;
/* fields valid only when processing streamed transaction */
static bool in_streamed_transaction = false;
@@ -1219,6 +1221,8 @@ apply_handle_begin(StringInfo s)
set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
remote_final_lsn = begin_data.final_lsn;
+ remote_commit_ts = begin_data.committime;
+ remote_xid = begin_data.xid;
maybe_start_skipping_changes(begin_data.final_lsn);
@@ -1745,6 +1749,10 @@ apply_handle_stream_start(StringInfo s)
/* extract XID of the top-level transaction */
stream_xid = logicalrep_read_stream_start(s, &first_segment);
+ remote_xid = stream_xid;
+ remote_final_lsn = InvalidXLogRecPtr;
+ remote_commit_ts = 0;
+
if (!TransactionIdIsValid(stream_xid))
ereport(ERROR,
(errcode(ERRCODE_PROTOCOL_VIOLATION),
@@ -5609,6 +5617,27 @@ start_apply(XLogRecPtr origin_startpos)
pgstat_report_subscription_error(MySubscription->oid,
MyLogicalRepWorker->type);
+ /*
+ * Insert any pending conflict log tuple under a new transaction.
+ */
+ if (MyLogicalRepWorker->conflict_log_tuple != NULL)
+ {
+ Relation conflictlogrel;
+ ConflictLogDest dest;
+
+ StartTransactionCommand();
+ PushActiveSnapshot(GetTransactionSnapshot());
+
+ /* Open conflict log table and insert the tuple. */
+ conflictlogrel = GetConflictLogTableInfo(&dest);
+ Assert((dest & CONFLICT_LOG_DEST_TABLE) != 0);
+ InsertConflictLogTuple(conflictlogrel);
+ table_close(conflictlogrel, RowExclusiveLock);
+
+ PopActiveSnapshot();
+ CommitTransactionCommand();
+ }
+
PG_RE_THROW();
}
}
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index af6deaa4297..b60c0b03e26 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -132,7 +132,6 @@ static const ConflictLogColumnDef ConflictLogSchema[] =
{ .attname = "local_conflicts", .atttypid = JSONARRAYOID }
};
-/* Define the count using the array size */
#define MAX_CONFLICT_ATTR_NUM (sizeof(ConflictLogSchema) / sizeof(ConflictLogSchema[0]))
extern bool GetTupleTransactionInfo(TupleTableSlot *localslot,
@@ -145,4 +144,7 @@ extern void ReportApplyConflict(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *remoteslot,
List *conflicttuples);
extern void InitConflictIndexes(ResultRelInfo *relInfo);
+extern Relation GetConflictLogTableInfo(ConflictLogDest *log_dest);
+extern void InsertConflictLogTuple(Relation conflictlogrel);
+extern bool ValidateConflictLogTable(Relation rel);
#endif
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index 6973a7423f9..c3076930dec 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -101,6 +101,9 @@ typedef struct LogicalRepWorker
*/
TransactionId oldest_nonremovable_xid;
+ /* A conflict log tuple that is prepared but not yet inserted. */
+ HeapTuple conflict_log_tuple;
+
/* Stats. */
XLogRecPtr last_lsn;
TimestampTz last_send_time;
@@ -256,6 +259,10 @@ extern PGDLLIMPORT bool InitializingApplyWorker;
extern PGDLLIMPORT List *table_states_not_ready;
+extern XLogRecPtr remote_final_lsn;
+extern TimestampTz remote_commit_ts;
+extern TransactionId remote_xid;
+
extern void logicalrep_worker_attach(int slot);
extern LogicalRepWorker *logicalrep_worker_find(LogicalRepWorkerType wtype,
Oid subid, Oid relid,
diff --git a/src/test/subscription/t/037_conflict_dest.pl b/src/test/subscription/t/037_conflict_dest.pl
new file mode 100644
index 00000000000..d33993530a4
--- /dev/null
+++ b/src/test/subscription/t/037_conflict_dest.pl
@@ -0,0 +1,181 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+# Test conflicts in logical replication
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+###############################
+# Setup
+###############################
+
+# Create a publisher node
+my $node_publisher = PostgreSQL::Test::Cluster->new('publisher');
+$node_publisher->init(allows_streaming => 'logical');
+$node_publisher->start;
+
+# Create a subscriber node
+my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
+$node_subscriber->init(allows_streaming => 'logical');
+$node_subscriber->start;
+
+# Create a table on publisher
+$node_publisher->safe_psql('postgres',
+ "CREATE TABLE conf_tab (a int PRIMARY KEY, b int UNIQUE, c int UNIQUE);");
+
+$node_publisher->safe_psql('postgres',
+ "CREATE TABLE conf_tab_2 (a int PRIMARY KEY, b int UNIQUE, c int UNIQUE);"
+);
+
+# Create same table on subscriber
+$node_subscriber->safe_psql('postgres',
+ "CREATE TABLE conf_tab (a int PRIMARY key, b int UNIQUE, c int UNIQUE);");
+
+$node_subscriber->safe_psql(
+ 'postgres', qq[
+ CREATE TABLE conf_tab_2 (a int PRIMARY KEY, b int, c int, unique(a,b)) PARTITION BY RANGE (a);
+ CREATE TABLE conf_tab_2_p1 PARTITION OF conf_tab_2 FOR VALUES FROM (MINVALUE) TO (100);
+]);
+
+# Setup logical replication
+my $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
+$node_publisher->safe_psql('postgres',
+ "CREATE PUBLICATION pub_tab FOR TABLE conf_tab, conf_tab_2");
+
+# Create the subscription
+my $appname = 'sub_tab';
+$node_subscriber->safe_psql(
+ 'postgres',
+ "CREATE SUBSCRIPTION sub_tab
+ CONNECTION '$publisher_connstr application_name=$appname'
+ PUBLICATION pub_tab WITH (conflict_log_destination=table)");
+
+# Wait for initial table sync to finish
+$node_subscriber->wait_for_subscription_sync($node_publisher, $appname);
+
+##################################################
+# INSERT data on Pub and Sub
+##################################################
+
+# Insert data in the publisher table
+$node_publisher->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (1,1,1);");
+
+# Insert data in the subscriber table
+$node_subscriber->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (2,2,2), (3,3,3), (4,4,4);");
+
+###############################################################################
+# Test conflict insertion into the internal conflict log table
+###############################################################################
+
+$node_subscriber->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (10, 10, 10);");
+
+# Get the internally generated table name
+my $subid = $node_subscriber->safe_psql('postgres',
+ "SELECT oid FROM pg_subscription WHERE subname = 'sub_tab';");
+my $conflict_table = "pg_conflict.pg_conflict_$subid";
+
+$node_publisher->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (10, 20, 30);");
+
+# Wait for the conflict to be logged
+my $log_check = $node_subscriber->poll_query_until(
+ 'postgres',
+ "SELECT count(*) > 0 FROM $conflict_table;"
+);
+
+is($log_check, 1, 'Conflict was successfully logged to the internal table');
+
+my $json_query = qq[
+ SELECT string_agg((unnested.j::json)->'key'->>'a', ',')
+ FROM (
+ SELECT unnest(local_conflicts) AS j
+ FROM $conflict_table
+ ) AS unnested;
+];
+
+my $all_keys = $node_subscriber->safe_psql('postgres', $json_query);
+
+# Verify that '10' is present in the resulting string
+like($all_keys, qr/10/, 'Verified that key 10 exists in the local_conflicts log');
+
+pass('Conflict type and data successfully validated in internal table');
+
+# Final cleanup for subsequent bidirectional tests in the script
+$node_subscriber->safe_psql('postgres', "TRUNCATE conf_tab;");
+
+###############################################################################
+# Test Case: update_missing
+###############################################################################
+
+# Sync a row, then delete it locally on subscriber
+$node_publisher->safe_psql('postgres', "INSERT INTO conf_tab VALUES (50, 50, 50);");
+$node_publisher->wait_for_catchup($appname);
+$node_subscriber->safe_psql('postgres', "DELETE FROM conf_tab WHERE a = 50;");
+
+# Trigger conflict by updating that row on publisher
+$node_publisher->safe_psql('postgres', "UPDATE conf_tab SET b = 500 WHERE a = 50;");
+
+# Wait for the apply worker to detect the missing row and log it
+$node_subscriber->poll_query_until('postgres',
+ "SELECT count(*) > 0 FROM $conflict_table WHERE conflict_type = 'update_missing';"
+) or die "Timed out waiting for update_missing conflict";
+
+my $upd_miss_check = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM $conflict_table WHERE conflict_type = 'update_missing';");
+is($upd_miss_check, 1, 'Verified update_missing conflict logged to internal table');
+
+$node_subscriber->safe_psql('postgres', "TRUNCATE conf_tab;");
+
+###############################################################################
+# Test Case: insert_exists (via secondary unique index)
+###############################################################################
+
+# 1. Subscriber has a row with b=100
+$node_subscriber->safe_psql('postgres', "INSERT INTO conf_tab VALUES (100, 100, 100);");
+
+# 2. Publisher inserts a NEW PK (101) but a DUPLICATE 'b' (100)
+$node_publisher->safe_psql('postgres', "INSERT INTO conf_tab VALUES (101, 100, 101);");
+
+# 3. Verify it appears as 'insert_exists' in your log table
+$node_subscriber->poll_query_until('postgres',
+ "SELECT count(*) > 0 FROM $conflict_table WHERE conflict_type = 'insert_exists' AND local_conflicts::text LIKE '%100%';"
+) or die "Timed out waiting for secondary index insert_exists conflict";
+
+pass('Logged insert_exists triggered by secondary unique index violation');
+
+$node_subscriber->safe_psql('postgres', "TRUNCATE conf_tab;");
+
+###############################################################################
+# CASE 3: Switching Destination to 'log' (Server Log Verification)
+###############################################################################
+
+# Switch destination
+$node_subscriber->safe_psql('postgres',
+ "ALTER SUBSCRIPTION sub_tab SET (conflict_log_destination = 'all');");
+
+$node_subscriber->safe_psql('postgres', "DELETE FROM $conflict_table;");
+# Trigger a conflict for server log (insert_exists)
+$node_subscriber->safe_psql('postgres', "INSERT INTO conf_tab VALUES (600, 600, 600);");
+$node_publisher->safe_psql('postgres', "INSERT INTO conf_tab VALUES (600, 700, 700);");
+
+# Wait for table log
+$node_subscriber->poll_query_until('postgres', "SELECT count(*) > 0 FROM $conflict_table;")
+ or die "Timed out waiting for insert_exists conflict";
+
+# Check subscriber server log
+my $log_found = $node_subscriber->wait_for_log(
+ qr/conflict detected on relation "public.conf_tab": conflict=insert_exists/
+);
+ok($log_found, 'Conflict correctly directed to server stderr log');
+
+# Verify table count DID NOT increase for this conflict
+my $table_check = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM $conflict_table WHERE local_conflicts::text LIKE '%600%';");
+is($table_check, 1, 'Table log was bypassed when destination set to log');
+
+done_testing();
--
2.49.0
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-05 11:18 Amit Kapila <amit.kapila16@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Amit Kapila @ 2026-01-05 11:18 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; vignesh C <vignesh21@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Sun, Jan 4, 2026 at 5:51 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Mon, Dec 29, 2025 at 11:32 AM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > On Thu, Dec 25, 2025 at 1:10 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > 5)
> > + /*
> > + * Establish an internal dependency between the conflict log table and the
> > + * subscription. By using DEPENDENCY_INTERNAL, we ensure the table is
> > + * automatically reaped when the subscription is dropped. This also
> > + * prevents the table from being dropped independently unless the
> > + * subscription itself is removed.
> > + */
> > + ObjectAddressSet(myself, RelationRelationId, relid);
> > + ObjectAddressSet(subaddr, SubscriptionRelationId, subid);
> > + recordDependencyOn(&myself, &subaddr, DEPENDENCY_INTERNAL);
> >
> > Now that we have pg_conflict, which is treated similarly to a system
> > catalog, I’m wondering whether we actually need to maintain this
> > dependency to prevent the CLT table or schema from being dropped.
> > Also, given that this currently goes against the convention that a
> > shared object cannot be present in pg_depend, could DropSubscription()
> > and AlterSubscription() instead handle dropping the table explicitly
> > in required scenarios?
>
> I thought about it while implementing the catalog schema, but then
> left as it is considering pg_toast tables also maintain internal
> dependency on the table, having said that, during drop
> subscription/alter subscription we anyway have to explicitly call the
> performDeletion of the table so seems like we are not achieving
> anything by maintaining dependency. Lets see what others have to say
> on this? I prefer removing this dependency because this is an
> exceptional case where we are maintaining dependency from a local
> object to a shared object. And now if we do not have any need for
> this we better get rid of it.
>
The main reason we wanted DEPENDENCY_INTERNAL was to prevent the user
from "breaking" the subscription by dropping the table or its schema.
But now with the introduction of the pg_conflict system schema, we can
avoid that. So, it makes sense to drop the table explicitly where
required. BTW, what happens if one drops the database which has a
pg_conflict schema and a conflict table and then tries to drop the
subscription? Do we need special handling for this if we remove the
dependency stuff?
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-05 11:40 Dilip Kumar <dilipbalaut@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
0 siblings, 0 replies; 912+ messages in thread
From: Dilip Kumar @ 2026-01-05 11:40 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; vignesh C <vignesh21@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Mon, Jan 5, 2026 at 4:49 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Sun, Jan 4, 2026 at 5:51 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Mon, Dec 29, 2025 at 11:32 AM shveta malik <shveta.malik@gmail.com> wrote:
> > >
> > > On Thu, Dec 25, 2025 at 1:10 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > >
> > > 5)
> > > + /*
> > > + * Establish an internal dependency between the conflict log table and the
> > > + * subscription. By using DEPENDENCY_INTERNAL, we ensure the table is
> > > + * automatically reaped when the subscription is dropped. This also
> > > + * prevents the table from being dropped independently unless the
> > > + * subscription itself is removed.
> > > + */
> > > + ObjectAddressSet(myself, RelationRelationId, relid);
> > > + ObjectAddressSet(subaddr, SubscriptionRelationId, subid);
> > > + recordDependencyOn(&myself, &subaddr, DEPENDENCY_INTERNAL);
> > >
> > > Now that we have pg_conflict, which is treated similarly to a system
> > > catalog, I’m wondering whether we actually need to maintain this
> > > dependency to prevent the CLT table or schema from being dropped.
> > > Also, given that this currently goes against the convention that a
> > > shared object cannot be present in pg_depend, could DropSubscription()
> > > and AlterSubscription() instead handle dropping the table explicitly
> > > in required scenarios?
> >
> > I thought about it while implementing the catalog schema, but then
> > left as it is considering pg_toast tables also maintain internal
> > dependency on the table, having said that, during drop
> > subscription/alter subscription we anyway have to explicitly call the
> > performDeletion of the table so seems like we are not achieving
> > anything by maintaining dependency. Lets see what others have to say
> > on this? I prefer removing this dependency because this is an
> > exceptional case where we are maintaining dependency from a local
> > object to a shared object. And now if we do not have any need for
> > this we better get rid of it.
> >
>
> The main reason we wanted DEPENDENCY_INTERNAL was to prevent the user
> from "breaking" the subscription by dropping the table or its schema.
> But now with the introduction of the pg_conflict system schema, we can
> avoid that. So, it makes sense to drop the table explicitly where
> required.
+1
BTW, what happens if one drops the database which has a
> pg_conflict schema and a conflict table and then tries to drop the
> subscription? Do we need special handling for this if we remove the
> dependency stuff?
The dropdb() is not allowed if there is any subscription created under
that database[1], so logically the 'subdbid' in pg_subscription and
the dbid under which the conflict log table is created will be same
and if any of the subscription is created under that database the
database drop is restricted. So I think we should be safe here.
[1]
/*
* Check if there are subscriptions defined in the target database.
*
* We can't drop them automatically because they might be holding
* resources in other databases/instances.
*/
if ((nsubscriptions = CountDBSubscriptions(db_id)) > 0)
ereport(ERROR,
(errcode(ERRCODE_OBJECT_IN_USE),
errmsg("database \"%s\" is being used by logical replication subscription",
dbname),
errdetail_plural("There is %d subscription.",
"There are %d subscriptions.",
nsubscriptions, nsubscriptions)));
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-05 15:00 vignesh C <vignesh21@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
1 sibling, 3 replies; 912+ messages in thread
From: vignesh C @ 2026-01-05 15:00 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Dilip Kumar <dilipbalaut@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Fri, 2 Jan 2026 at 12:06, shveta malik <shveta.malik@gmail.com> wrote:
>
> On Fri, Dec 26, 2025 at 8:58 PM vignesh C <vignesh21@gmail.com> wrote:
> >
> >
> > Here is a rebased version of the remaining patches.
> >
>
> Thank You Vignesh. Please find a few comments on 004:
>
>
> 1)
> IIUC, SubscriptionConflictrelIndexId is an unique index on sub-oid and
> conf-relid, but we use it only on relid as key. Why didn't we create
> it only on 'conf-relid' alone? Using a composite unique index is
> guaranteed to give unique row only when all keys are used, but for a
> single key, a unique row is not guaranteed. In our case, it will be a
> unique row as conflict-relid is not shared, but still as an overall
> general concept, it may not be.
As we are searching only on subconflictlogrelid, index on
subconflictlogrelid is sufficient. I have modified it.
> 2)
> IsConflictLogTable():
> + if (OidIsValid(subform->subconflictlogrelid))
>
> Do we need this check? Since we’ve already performed an index access
> using subconflictlogrelid as the key, isn’t it guaranteed to always be
> valid?
It is not required, removed it
> 3)
> Please update the commit message to indicate that this patch makes CLT
> publishable if a publication is explicitly created on it, else few
> changes become very confusing due to unclear intent.
Included this in the commit message.
> 4)
> pg_relation_is_publishable():
>
> /* Subscription conflict log tables are not published */
> - result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple)) &&
> - !IsConflictLogTable(relid);
>
> Comment should be removed too.
Removed
> 5)
> We need to remove below comment:
>
> * Note: Conflict log tables are not publishable. However, we intentionally
> * skip this check here because this function is called for every change and
> * performing this check during every change publication is costly. To ensure
> * unpublishable entries are ignored without incurring performance overhead,
> * tuples inserted into the conflict log table uses the HEAP_INSERT_NO_LOGICAL
> * flag. This allows the decoding layer to bypass these entries automatically.
> */
> bool
> is_publishable_relation(Relation rel)
Removed
> 6)
> get_rel_sync_entry:
> + /* is this relation used for conflict logging? */
> + isconflictlogrel = IsConflictLogTable(relid);
>
> Shall we add a comment indicating the intent of change in this
> function. Something like:
>
> /*
> * Check whether this is a conflict log table. If so, avoid publishing it via
> * FOR ALL TABLES or FOR TABLES IN SCHEMA publications, but still allow it
> * to be published through a publication explicitly created for this table.
> */
Included
The attached v19 patch has the changes for the same.
Regards,
Vignesh
Attachments:
[text/x-patch] v19-0001-Add-configurable-conflict-log-table-for-Logical-.patch (101.0K, ../../CALDaNm2YOOdJ25X1sJ+DYz37K6Qi4g0ZNFHb_pQMF9UqancnEA@mail.gmail.com/2-v19-0001-Add-configurable-conflict-log-table-for-Logical-.patch)
download | inline diff:
From 6df7e487f9e047083235dc28975a4cfc1b72511b Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 17 Dec 2025 11:53:47 +0530
Subject: [PATCH v19 1/6] Add configurable conflict log table for Logical
Replication
This patch adds a feature to provide a structured, queryable record of all
logical replication conflicts. The current approach of logging conflicts as
plain text in the server logs makes it difficult to query, analyze, and
use for external monitoring and automation.
This patch addresses these limitations by introducing a configurable
conflict_log_destination=('log'/'table'/'all') option in the CREATE SUBSCRIPTION
command.
If the user chooses to enable logging to a table (by selecting 'table' or 'all'),
an internal logging table named conflict_log_table_<subid> is automatically
created within a dedicated, system-managed namespace named pg_conflict. This table
is linked to the subscription via an internal dependency, ensuring it is
automatically dropped when the subscription is removed.
The conflict details, including the original and remote tuples, are stored in JSON
columns, providing a flexible format to accommodate different table schemas.
The log table captures essential attributes such as local and remote transaction IDs,
LSNs, commit timestamps, and conflict type, providing a complete record for post-mortem
analysis.
This feature will make logical replication conflicts easier to monitor and manage,
significantly improving the overall resilience and operability of replication setups.
The conflict log tables will not be included in a publication, even if the publication
is configured to include ALL TABLES or ALL TABLES IN SCHEMA.
---
src/backend/catalog/catalog.c | 27 +-
src/backend/catalog/heap.c | 3 +-
src/backend/catalog/namespace.c | 6 +
src/backend/catalog/pg_publication.c | 26 +-
src/backend/catalog/pg_subscription.c | 7 +
src/backend/commands/subscriptioncmds.c | 284 ++++++++++++++++-
src/bin/psql/describe.c | 21 +-
src/bin/psql/tab-complete.in.c | 8 +-
src/include/catalog/catalog.h | 2 +
src/include/catalog/pg_namespace.dat | 3 +
src/include/catalog/pg_subscription.h | 11 +
src/include/commands/subscriptioncmds.h | 4 +
src/include/replication/conflict.h | 56 ++++
src/test/regress/expected/subscription.out | 336 +++++++++++++++------
src/test/regress/sql/subscription.sql | 118 ++++++++
src/tools/pgindent/typedefs.list | 2 +
16 files changed, 808 insertions(+), 106 deletions(-)
diff --git a/src/backend/catalog/catalog.c b/src/backend/catalog/catalog.c
index 7be49032934..d438dc682ec 100644
--- a/src/backend/catalog/catalog.c
+++ b/src/backend/catalog/catalog.c
@@ -86,7 +86,8 @@ bool
IsSystemClass(Oid relid, Form_pg_class reltuple)
{
/* IsCatalogRelationOid is a bit faster, so test that first */
- return (IsCatalogRelationOid(relid) || IsToastClass(reltuple));
+ return (IsCatalogRelationOid(relid) || IsToastClass(reltuple)
+ || IsConflictClass(reltuple));
}
/*
@@ -230,6 +231,18 @@ IsToastClass(Form_pg_class reltuple)
return IsToastNamespace(relnamespace);
}
+/*
+ * IsConflictClass - Check if the given pg_class tuple belongs to the conflict
+ * namespace.
+ */
+bool
+IsConflictClass(Form_pg_class reltuple)
+{
+ Oid relnamespace = reltuple->relnamespace;
+
+ return IsConflictNamespace(relnamespace);
+}
+
/*
* IsCatalogNamespace
* True iff namespace is pg_catalog.
@@ -264,6 +277,18 @@ IsToastNamespace(Oid namespaceId)
isTempToastNamespace(namespaceId);
}
+/*
+ * IsConflictNamespace
+ * True iff namespace is pg_conflict.
+ *
+ * Does not perform any catalog accesses.
+ */
+bool
+IsConflictNamespace(Oid namespaceId)
+{
+ return namespaceId == PG_CONFLICT_NAMESPACE;
+}
+
/*
* IsReservedName
diff --git a/src/backend/catalog/heap.c b/src/backend/catalog/heap.c
index 606434823cf..10dadf378a4 100644
--- a/src/backend/catalog/heap.c
+++ b/src/backend/catalog/heap.c
@@ -314,7 +314,8 @@ heap_create(const char *relname,
*/
if (!allow_system_table_mods &&
((IsCatalogNamespace(relnamespace) && relkind != RELKIND_INDEX) ||
- IsToastNamespace(relnamespace)) &&
+ IsToastNamespace(relnamespace) ||
+ IsConflictNamespace(relnamespace)) &&
IsNormalProcessingMode())
ereport(ERROR,
(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
diff --git a/src/backend/catalog/namespace.c b/src/backend/catalog/namespace.c
index c3b79a2ba48..cc7f0a045a6 100644
--- a/src/backend/catalog/namespace.c
+++ b/src/backend/catalog/namespace.c
@@ -3539,6 +3539,12 @@ CheckSetNamespace(Oid oldNspOid, Oid nspOid)
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("cannot move objects into or out of TOAST schema")));
+
+ /* similarly for CONFLICT schema */
+ if (nspOid == PG_CONFLICT_NAMESPACE || oldNspOid == PG_CONFLICT_NAMESPACE)
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot move objects into or out of CONFLICT schema")));
}
/*
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index 9a4791c573e..cb383a5ce04 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -31,6 +31,7 @@
#include "catalog/pg_publication_rel.h"
#include "catalog/pg_type.h"
#include "commands/publicationcmds.h"
+#include "commands/subscriptioncmds.h"
#include "funcapi.h"
#include "utils/array.h"
#include "utils/builtins.h"
@@ -85,6 +86,15 @@ check_publication_add_relation(Relation targetrel)
errmsg("cannot add relation \"%s\" to publication",
RelationGetRelationName(targetrel)),
errdetail("This operation is not supported for unlogged tables.")));
+
+ /* Can't be conflict log table */
+ if (IsConflictLogTable(RelationGetRelid(targetrel)))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot add relation \"%s.%s\" to publication",
+ get_namespace_name(RelationGetNamespace(targetrel)),
+ RelationGetRelationName(targetrel)),
+ errdetail("This operation is not supported for conflict log tables.")));
}
/*
@@ -145,6 +155,13 @@ is_publishable_class(Oid relid, Form_pg_class reltuple)
/*
* Another variant of is_publishable_class(), taking a Relation.
+ *
+ * Note: Conflict log tables are not publishable. However, we intentionally
+ * skip this check here because this function is called for every change and
+ * performing this check during every change publication is costly. To ensure
+ * unpublishable entries are ignored without incurring performance overhead,
+ * tuples inserted into the conflict log table uses the HEAP_INSERT_NO_LOGICAL
+ * flag. This allows the decoding layer to bypass these entries automatically.
*/
bool
is_publishable_relation(Relation rel)
@@ -169,7 +186,10 @@ pg_relation_is_publishable(PG_FUNCTION_ARGS)
tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
if (!HeapTupleIsValid(tuple))
PG_RETURN_NULL();
- result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple));
+
+ /* Subscription conflict log tables are not published */
+ result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple)) &&
+ !IsConflictLogTable(relid);
ReleaseSysCache(tuple);
PG_RETURN_BOOL(result);
}
@@ -890,7 +910,9 @@ GetAllPublicationRelations(char relkind, bool pubviaroot)
Form_pg_class relForm = (Form_pg_class) GETSTRUCT(tuple);
Oid relid = relForm->oid;
+ /* Subscription conflict log tables are not published */
if (is_publishable_class(relid, relForm) &&
+ !IsConflictLogTable(relid) &&
!(relForm->relispartition && pubviaroot))
result = lappend_oid(result, relid);
}
@@ -1018,7 +1040,7 @@ GetSchemaPublicationRelations(Oid schemaid, PublicationPartOpt pub_partopt)
Oid relid = relForm->oid;
char relkind;
- if (!is_publishable_class(relid, relForm))
+ if (!is_publishable_class(relid, relForm) || IsConflictLogTable(relid))
continue;
relkind = get_rel_relkind(relid);
diff --git a/src/backend/catalog/pg_subscription.c b/src/backend/catalog/pg_subscription.c
index 2b103245290..285a598497d 100644
--- a/src/backend/catalog/pg_subscription.c
+++ b/src/backend/catalog/pg_subscription.c
@@ -106,6 +106,7 @@ GetSubscription(Oid subid, bool missing_ok)
sub->retaindeadtuples = subform->subretaindeadtuples;
sub->maxretention = subform->submaxretention;
sub->retentionactive = subform->subretentionactive;
+ sub->conflictlogrelid = subform->subconflictlogrelid;
/* Get conninfo */
datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
@@ -141,6 +142,12 @@ GetSubscription(Oid subid, bool missing_ok)
Anum_pg_subscription_suborigin);
sub->origin = TextDatumGetCString(datum);
+ /* Get conflict log destination */
+ datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
+ tup,
+ Anum_pg_subscription_subconflictlogdest);
+ sub->conflictlogdest = TextDatumGetCString(datum);
+
/* Is the subscription owner a superuser? */
sub->ownersuperuser = superuser_arg(sub->owner);
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index d6674f20fc2..4b2d98c9ed2 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -15,25 +15,31 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/htup_details.h"
#include "access/table.h"
#include "access/twophase.h"
#include "access/xact.h"
#include "catalog/catalog.h"
#include "catalog/dependency.h"
+#include "catalog/heap.h"
#include "catalog/indexing.h"
#include "catalog/namespace.h"
#include "catalog/objectaccess.h"
#include "catalog/objectaddress.h"
+#include "catalog/pg_am_d.h"
#include "catalog/pg_authid_d.h"
#include "catalog/pg_database_d.h"
+#include "catalog/pg_namespace.h"
#include "catalog/pg_subscription.h"
#include "catalog/pg_subscription_rel.h"
#include "catalog/pg_type.h"
+#include "commands/comment.h"
#include "commands/defrem.h"
#include "commands/event_trigger.h"
#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "miscadmin.h"
#include "nodes/makefuncs.h"
#include "pgstat.h"
@@ -51,6 +57,7 @@
#include "utils/lsyscache.h"
#include "utils/memutils.h"
#include "utils/pg_lsn.h"
+#include "utils/regproc.h"
#include "utils/syscache.h"
/*
@@ -75,6 +82,7 @@
#define SUBOPT_MAX_RETENTION_DURATION 0x00008000
#define SUBOPT_LSN 0x00010000
#define SUBOPT_ORIGIN 0x00020000
+#define SUBOPT_CONFLICT_LOG_DEST 0x00040000
/* check if the 'val' has 'bits' set */
#define IsSet(val, bits) (((val) & (bits)) == (bits))
@@ -103,6 +111,7 @@ typedef struct SubOpts
bool retaindeadtuples;
int32 maxretention;
char *origin;
+ ConflictLogDest logdest;
XLogRecPtr lsn;
} SubOpts;
@@ -135,7 +144,7 @@ static List *merge_publications(List *oldpublist, List *newpublist, bool addpub,
static void ReportSlotConnectionError(List *rstates, Oid subid, char *slotname, char *err);
static void CheckAlterSubOption(Subscription *sub, const char *option,
bool slot_needs_update, bool isTopLevel);
-
+static Oid create_conflict_log_table(Oid subid, char *subname);
/*
* Common option parsing function for CREATE and ALTER SUBSCRIPTION commands.
@@ -191,6 +200,8 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->maxretention = 0;
if (IsSet(supported_opts, SUBOPT_ORIGIN))
opts->origin = pstrdup(LOGICALREP_ORIGIN_ANY);
+ if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DEST))
+ opts->logdest = CONFLICT_LOG_DEST_LOG;
/* Parse options */
foreach(lc, stmt_options)
@@ -402,6 +413,20 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->specified_opts |= SUBOPT_LSN;
opts->lsn = lsn;
}
+ else if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DEST) &&
+ strcmp(defel->defname, "conflict_log_destination") == 0)
+ {
+ char *val;
+ ConflictLogDest dest;
+
+ if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_LOG_DEST))
+ errorConflictingDefElem(defel, pstate);
+
+ val = defGetString(defel);
+ dest = GetLogDestination(val);
+ opts->logdest = dest;
+ opts->specified_opts |= SUBOPT_CONFLICT_LOG_DEST;
+ }
else
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
@@ -612,7 +637,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
SUBOPT_DISABLE_ON_ERR | SUBOPT_PASSWORD_REQUIRED |
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
- SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN);
+ SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_DEST);
parse_subscription_options(pstate, stmt->options, supported_opts, &opts);
/*
@@ -747,6 +773,31 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
values[Anum_pg_subscription_suborigin - 1] =
CStringGetTextDatum(opts.origin);
+ /* Always set the destination, default will be 'log'. */
+ values[Anum_pg_subscription_subconflictlogdest - 1] =
+ CStringGetTextDatum(ConflictLogDestNames[opts.logdest]);
+
+ /*
+ * If logging to a table is required, physically create the logging
+ * relation and store its OID in the catalog.
+ */
+ if (opts.logdest == CONFLICT_LOG_DEST_TABLE ||
+ opts.logdest == CONFLICT_LOG_DEST_ALL)
+ {
+ Oid logrelid;
+
+ /* Store the Oid returned from creation. */
+ logrelid = create_conflict_log_table(subid, stmt->subname);
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(logrelid);
+ }
+ else
+ {
+ /* Destination is 'log'; no table is needed. */
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(InvalidOid);
+ }
+
tup = heap_form_tuple(RelationGetDescr(rel), values, nulls);
/* Insert tuple into catalog. */
@@ -1410,7 +1461,8 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
SUBOPT_MAX_RETENTION_DURATION |
- SUBOPT_ORIGIN);
+ SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_DEST);
parse_subscription_options(pstate, stmt->options,
supported_opts, &opts);
@@ -1665,6 +1717,63 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
origin = opts.origin;
}
+ if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_LOG_DEST))
+ {
+ ConflictLogDest old_dest =
+ GetLogDestination(sub->conflictlogdest);
+
+ if (opts.logdest != old_dest)
+ {
+ bool want_table =
+ IsSet(opts.logdest, CONFLICT_LOG_DEST_TABLE);
+ bool has_oldtable =
+ IsSet(old_dest, CONFLICT_LOG_DEST_TABLE);
+
+ values[Anum_pg_subscription_subconflictlogdest - 1] =
+ CStringGetTextDatum(ConflictLogDestNames[opts.logdest]);
+ replaces[Anum_pg_subscription_subconflictlogdest - 1] = true;
+
+ if (want_table && !has_oldtable)
+ {
+ Oid relid;
+
+ relid = create_conflict_log_table(subid, sub->name);
+
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(relid);
+ replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
+ true;
+ }
+ else if (!want_table && has_oldtable)
+ {
+ ObjectAddress object;
+
+ /*
+ * Conflict log tables are recorded as internal
+ * dependencies of the subscription. Drop the
+ * table if it is not required anymore to avoid
+ * stale or orphaned relations.
+ *
+ * XXX: At present, only conflict log tables are
+ * managed this way. In future if we introduce
+ * additional internal dependencies, we may need
+ * a targeted deletion to avoid deletion of any
+ * other objects.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId,
+ subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(InvalidOid);
+ replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
+ true;
+ }
+ }
+ }
+
update_tuple = true;
break;
}
@@ -2027,6 +2136,7 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
Form_pg_subscription form;
List *rstates;
bool must_use_password;
+ ObjectAddress object;
/*
* The launcher may concurrently start a new worker for this subscription.
@@ -2184,6 +2294,19 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
/* Clean up dependencies */
deleteSharedDependencyRecordsFor(SubscriptionRelationId, subid, 0);
+ /*
+ * Conflict log tables are recorded as internal dependencies of the
+ * subscription. We must drop the dependent objects before the
+ * subscription itself is removed. By using
+ * PERFORM_DELETION_SKIP_ORIGINAL, we ensure that only the conflict log
+ * table is reaped while the subscription remains for the final deletion
+ * step.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId, subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
/* Remove any associated relation synchronization states. */
RemoveSubscriptionRel(subid, InvalidOid);
@@ -3190,3 +3313,158 @@ defGetStreamingMode(DefElem *def)
def->defname)));
return LOGICALREP_STREAM_OFF; /* keep compiler quiet */
}
+
+/*
+ * Builds the TupleDesc for the conflict log table.
+ */
+static TupleDesc
+create_conflict_log_table_tupdesc(void)
+{
+ TupleDesc tupdesc;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_CONFLICT_ATTR_NUM);
+
+ for (int i = 0; i < MAX_CONFLICT_ATTR_NUM; i++)
+ {
+ Oid type_oid = ConflictLogSchema[i].atttypid;
+
+ /*
+ * Special handling for the JSON array type for proper
+ * TupleDescInitEntry call.
+ */
+ if (type_oid == JSONARRAYOID)
+ type_oid = get_array_type(JSONOID);
+
+ TupleDescInitEntry(tupdesc, i + 1,
+ ConflictLogSchema[i].attname,
+ type_oid,
+ -1, 0);
+ }
+
+ return BlessTupleDesc(tupdesc);
+}
+
+/*
+ * Create a structured conflict log table for a subscription.
+ *
+ * The table is created within the dedicated 'pg_conflict' namespace, which
+ * is system-managed. The table name is generated automatically using the
+ * subscription's OID (e.g., "pg_conflict_<subid>") to ensure uniqueness
+ * within the cluster and to avoid collisions during subscription renames.
+ */
+static Oid
+create_conflict_log_table(Oid subid, char *subname)
+{
+ TupleDesc tupdesc;
+ Oid relid;
+ ObjectAddress myself;
+ ObjectAddress subaddr;
+ char relname[NAMEDATALEN];
+
+ snprintf(relname, NAMEDATALEN, "pg_conflict_%u", subid);
+
+ /* There can not be an existing table with the same name. */
+ Assert(!OidIsValid(get_relname_relid(relname, PG_CONFLICT_NAMESPACE)));
+
+ /* Build the tuple descriptor for the new table. */
+ tupdesc = create_conflict_log_table_tupdesc();
+
+ /* Create conflict log table. */
+ relid = heap_create_with_catalog(relname,
+ PG_CONFLICT_NAMESPACE,
+ 0,
+ InvalidOid,
+ InvalidOid,
+ InvalidOid,
+ GetUserId(),
+ HEAP_TABLE_AM_OID,
+ tupdesc,
+ NIL,
+ RELKIND_RELATION,
+ RELPERSISTENCE_PERMANENT,
+ false,
+ false,
+ ONCOMMIT_NOOP,
+ (Datum) 0,
+ false,
+ true,
+ false,
+ InvalidOid,
+ NULL);
+
+ /*
+ * Establish an internal dependency between the conflict log table and
+ * the subscription. By using DEPENDENCY_INTERNAL, we ensure the table
+ * is automatically reaped when the subscription is dropped. This also
+ * prevents the table from being dropped independently unless the
+ * subscription itself is removed.
+ */
+ ObjectAddressSet(myself, RelationRelationId, relid);
+ ObjectAddressSet(subaddr, SubscriptionRelationId, subid);
+ recordDependencyOn(&myself, &subaddr, DEPENDENCY_INTERNAL);
+
+ /* Release tuple descriptor memory. */
+ FreeTupleDesc(tupdesc);
+
+ return relid;
+}
+
+/*
+ * GetLogDestination
+ *
+ * Convert string to enum by comparing against standardized labels.
+ */
+ConflictLogDest
+GetLogDestination(const char *dest)
+{
+ /* Empty string or NULL defaults to LOG. */
+ if (dest == NULL || dest[0] == '\0' || pg_strcasecmp(dest, "log") == 0)
+ return CONFLICT_LOG_DEST_LOG;
+
+ if (pg_strcasecmp(dest,
+ ConflictLogDestNames[CONFLICT_LOG_DEST_TABLE]) == 0)
+ return CONFLICT_LOG_DEST_TABLE;
+
+ if (pg_strcasecmp(dest, ConflictLogDestNames[CONFLICT_LOG_DEST_ALL]) == 0)
+ return CONFLICT_LOG_DEST_ALL;
+
+ /* Unrecognized string. */
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("unrecognized conflict_log_destination value: \"%s\"", dest),
+ errhint("Valid values are \"log\", \"table\", and \"all\".")));
+}
+
+/*
+ * Check if the specified relation is used as a conflict log table by any
+ * subscription.
+ */
+bool
+IsConflictLogTable(Oid relid)
+{
+ Relation rel;
+ TableScanDesc scan;
+ HeapTuple tup;
+ bool is_clt = false;
+
+ rel = table_open(SubscriptionRelationId, AccessShareLock);
+ scan = table_beginscan_catalog(rel, 0, NULL);
+
+ while (HeapTupleIsValid(tup = heap_getnext(scan, ForwardScanDirection)))
+ {
+ Form_pg_subscription subform = (Form_pg_subscription) GETSTRUCT(tup);
+
+ /* Direct Oid comparison from catalog */
+ if (OidIsValid(subform->subconflictlogrelid) &&
+ subform->subconflictlogrelid == relid)
+ {
+ is_clt = true;
+ break;
+ }
+ }
+
+ table_endscan(scan);
+ table_close(rel, AccessShareLock);
+
+ return is_clt;
+}
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index 3584c4e1428..20f08e548ba 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -6806,7 +6806,7 @@ describeSubscriptions(const char *pattern, bool verbose)
printQueryOpt myopt = pset.popt;
static const bool translate_columns[] = {false, false, false, false,
false, false, false, false, false, false, false, false, false, false,
- false, false, false, false};
+ false, false, false, false, false};
if (pset.sversion < 100000)
{
@@ -6900,15 +6900,22 @@ describeSubscriptions(const char *pattern, bool verbose)
appendPQExpBuffer(&buf,
", subskiplsn AS \"%s\"\n",
gettext_noop("Skip LSN"));
+
+ /* Conflict log destination is supported in v19 and higher */
+ if (pset.sversion >= 190000)
+ {
+ appendPQExpBuffer(&buf,
+ ", subconflictlogdest AS \"%s\"\n",
+ gettext_noop("Conflict log destination"));
+ }
}
/* Only display subscriptions in current database. */
- appendPQExpBufferStr(&buf,
- "FROM pg_catalog.pg_subscription\n"
- "WHERE subdbid = (SELECT oid\n"
- " FROM pg_catalog.pg_database\n"
- " WHERE datname = pg_catalog.current_database())");
-
+ appendPQExpBuffer(&buf,
+ "FROM pg_catalog.pg_subscription "
+ "WHERE subdbid = (SELECT oid\n"
+ " FROM pg_catalog.pg_database\n"
+ " WHERE datname = pg_catalog.current_database())");
if (!validateSQLNamePattern(&buf, pattern, true, false,
NULL, "subname", NULL,
NULL,
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index d81f2fcdbe6..e5eb434c90c 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -2344,8 +2344,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("(", "PUBLICATION");
/* ALTER SUBSCRIPTION <name> SET ( */
else if (Matches("ALTER", "SUBSCRIPTION", MatchAny, MatchAnyN, "SET", "("))
- COMPLETE_WITH("binary", "disable_on_error", "failover",
- "max_retention_duration", "origin",
+ COMPLETE_WITH("binary", "conflict_log_destination", "disable_on_error",
+ "failover", "max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
"synchronous_commit", "two_phase");
@@ -3851,8 +3851,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("WITH (");
/* Complete "CREATE SUBSCRIPTION <name> ... WITH ( <opt>" */
else if (Matches("CREATE", "SUBSCRIPTION", MatchAnyN, "WITH", "("))
- COMPLETE_WITH("binary", "connect", "copy_data", "create_slot",
- "disable_on_error", "enabled", "failover",
+ COMPLETE_WITH("binary", "conflict_log_destination", "connect", "copy_data",
+ "create_slot", "disable_on_error", "enabled", "failover",
"max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
diff --git a/src/include/catalog/catalog.h b/src/include/catalog/catalog.h
index a9d6e8ea986..8193229f2e2 100644
--- a/src/include/catalog/catalog.h
+++ b/src/include/catalog/catalog.h
@@ -25,6 +25,7 @@ extern bool IsInplaceUpdateRelation(Relation relation);
extern bool IsSystemClass(Oid relid, Form_pg_class reltuple);
extern bool IsToastClass(Form_pg_class reltuple);
+extern bool IsConflictClass(Form_pg_class reltuple);
extern bool IsCatalogRelationOid(Oid relid);
extern bool IsCatalogTextUniqueIndexOid(Oid relid);
@@ -32,6 +33,7 @@ extern bool IsInplaceUpdateOid(Oid relid);
extern bool IsCatalogNamespace(Oid namespaceId);
extern bool IsToastNamespace(Oid namespaceId);
+extern bool IsConflictNamespace(Oid namespaceId);
extern bool IsReservedName(const char *name);
diff --git a/src/include/catalog/pg_namespace.dat b/src/include/catalog/pg_namespace.dat
index 3075e142c73..c6e10150b21 100644
--- a/src/include/catalog/pg_namespace.dat
+++ b/src/include/catalog/pg_namespace.dat
@@ -18,6 +18,9 @@
{ oid => '99', oid_symbol => 'PG_TOAST_NAMESPACE',
descr => 'reserved schema for TOAST tables',
nspname => 'pg_toast', nspacl => '_null_' },
+{ oid => '1382', oid_symbol => 'PG_CONFLICT_NAMESPACE',
+ descr => 'reserved schema for subscription-specific conflict tables',
+ nspname => 'pg_conflict', nspacl => '_null_' },
# update dumpNamespace() if changing this descr
{ oid => '2200', oid_symbol => 'PG_PUBLIC_NAMESPACE',
descr => 'standard public schema',
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index f3571d2bfcf..4aa29ea15d4 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -90,6 +90,7 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
* exceeded max_retention_duration, when
* defined */
+ Oid subconflictlogrelid; /* Relid of the conflict log table. */
#ifdef CATALOG_VARLEN /* variable-length fields start here */
/* Connection string to the publisher */
text subconninfo BKI_FORCE_NOT_NULL;
@@ -103,6 +104,14 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
/* List of publications subscribed to */
text subpublications[1] BKI_FORCE_NOT_NULL;
+ /*
+ * Strategy for logging replication conflicts:
+ * 'log' - server log only,
+ * 'table' - internal table only,
+ * 'all' - both log and table.
+ */
+ text subconflictlogdest;
+
/* Only publish data originating from the specified origin */
text suborigin BKI_DEFAULT(LOGICALREP_ORIGIN_ANY);
#endif
@@ -152,12 +161,14 @@ typedef struct Subscription
* and the retention duration has not
* exceeded max_retention_duration, when
* defined */
+ Oid conflictlogrelid; /* conflict log table Oid */
char *conninfo; /* Connection string to the publisher */
char *slotname; /* Name of the replication slot */
char *synccommit; /* Synchronous commit setting for worker */
List *publications; /* List of publication names to subscribe to */
char *origin; /* Only publish data originating from the
* specified origin */
+ char *conflictlogdest; /* Conflict log destination */
} Subscription;
#ifdef EXPOSE_TO_CLIENT_CODE
diff --git a/src/include/commands/subscriptioncmds.h b/src/include/commands/subscriptioncmds.h
index 63504232a14..bc4a92af356 100644
--- a/src/include/commands/subscriptioncmds.h
+++ b/src/include/commands/subscriptioncmds.h
@@ -17,6 +17,7 @@
#include "catalog/objectaddress.h"
#include "parser/parse_node.h"
+#include "replication/conflict.h"
extern ObjectAddress CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bool isTopLevel);
@@ -36,4 +37,7 @@ extern void CheckSubDeadTupleRetention(bool check_guc, bool sub_disabled,
bool retention_active,
bool max_retention_set);
+extern ConflictLogDest GetLogDestination(const char *dest);
+extern bool IsConflictLogTable(Oid relid);
+
#endif /* SUBSCRIPTIONCMDS_H */
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index d538274637f..af6deaa4297 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -10,6 +10,7 @@
#define CONFLICT_H
#include "access/xlogdefs.h"
+#include "catalog/pg_type.h"
#include "nodes/pg_list.h"
#include "utils/timestamp.h"
@@ -79,6 +80,61 @@ typedef struct ConflictTupleInfo
* conflicting local row occurred */
} ConflictTupleInfo;
+/*
+ * Conflict log destination types.
+ *
+ * These values are defined as bitmask flags to allow for multiple simultaneous
+ * logging destinations (e.g., logging to both system logs and a table).
+ * Internally, we use these for bitwise comparisons (IsSet), but the string
+ * representation is stored in pg_subscription.subconflictlogdest.
+ */
+typedef enum ConflictLogDest
+{
+ /* Log conflicts to the server logs */
+ CONFLICT_LOG_DEST_LOG = 1 << 0, /* 0x01 */
+
+ /* Log conflicts to an internally managed table */
+ CONFLICT_LOG_DEST_TABLE = 1 << 1, /* 0x02 */
+
+ /* Convenience flag for all supported destinations */
+ CONFLICT_LOG_DEST_ALL = (CONFLICT_LOG_DEST_LOG | CONFLICT_LOG_DEST_TABLE)
+} ConflictLogDest;
+
+/*
+ * Array mapping for converting internal enum to string.
+ */
+static const char *const ConflictLogDestNames[] = {
+ [CONFLICT_LOG_DEST_LOG] = "log",
+ [CONFLICT_LOG_DEST_TABLE] = "table",
+ [CONFLICT_LOG_DEST_ALL] = "all"
+};
+
+/* Structure to hold metadata for one column of the conflict log table */
+typedef struct ConflictLogColumnDef
+{
+ const char *attname; /* Column name */
+ Oid atttypid; /* Data type OID */
+} ConflictLogColumnDef;
+
+/* The single source of truth for the conflict log table schema */
+static const ConflictLogColumnDef ConflictLogSchema[] =
+{
+ { .attname = "relid", .atttypid = OIDOID },
+ { .attname = "schemaname", .atttypid = TEXTOID },
+ { .attname = "relname", .atttypid = TEXTOID },
+ { .attname = "conflict_type", .atttypid = TEXTOID },
+ { .attname = "remote_xid", .atttypid = XIDOID },
+ { .attname = "remote_commit_lsn",.atttypid = LSNOID },
+ { .attname = "remote_commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "remote_origin", .atttypid = TEXTOID },
+ { .attname = "replica_identity", .atttypid = JSONOID },
+ { .attname = "remote_tuple", .atttypid = JSONOID },
+ { .attname = "local_conflicts", .atttypid = JSONARRAYOID }
+};
+
+/* Define the count using the array size */
+#define MAX_CONFLICT_ATTR_NUM (sizeof(ConflictLogSchema) / sizeof(ConflictLogSchema[0]))
+
extern bool GetTupleTransactionInfo(TupleTableSlot *localslot,
TransactionId *xmin,
RepOriginId *localorigin,
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index b3eccd8afe3..d5f8abe9325 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -116,18 +116,18 @@ CREATE SUBSCRIPTION regress_testsub4 CONNECTION 'dbname=regress_doesnotexist' PU
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub4 SET (origin = any);
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub3;
@@ -145,10 +145,10 @@ ALTER SUBSCRIPTION regress_testsub CONNECTION 'foobar';
ERROR: invalid connection string syntax: missing "=" after "foobar" in connection info string
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET PUBLICATION testpub2, testpub3 WITH (refresh = false);
@@ -157,10 +157,10 @@ ALTER SUBSCRIPTION regress_testsub SET (slot_name = 'newname');
ALTER SUBSCRIPTION regress_testsub SET (password_required = false);
ALTER SUBSCRIPTION regress_testsub SET (run_as_owner = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (password_required = true);
@@ -176,10 +176,10 @@ ERROR: unrecognized subscription parameter: "create_slot"
-- ok
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/12345');
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345 | log
(1 row)
-- ok - with lsn = NONE
@@ -188,10 +188,10 @@ ALTER SUBSCRIPTION regress_testsub SKIP (lsn = NONE);
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/0');
ERROR: invalid WAL location (LSN): 0/0
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
BEGIN;
@@ -223,10 +223,10 @@ ALTER SUBSCRIPTION regress_testsub_foo SET (synchronous_commit = foobar);
ERROR: invalid value for parameter "synchronous_commit": "foobar"
HINT: Available values: local, remote_write, remote_apply, on, off.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
----------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+---------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
-- rename back to keep the rest simple
@@ -255,19 +255,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (binary = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -279,27 +279,27 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = parallel);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- fail - publication already exists
@@ -314,10 +314,10 @@ ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refr
ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refresh = false);
ERROR: publication "testpub1" is already in subscription "regress_testsub"
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- fail - publication used more than once
@@ -332,10 +332,10 @@ ERROR: publication "testpub3" is not in subscription "regress_testsub"
-- ok - delete publications
ALTER SUBSCRIPTION regress_testsub DROP PUBLICATION testpub1, testpub2 WITH (refresh = false);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -371,19 +371,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- we can alter streaming when two_phase enabled
ALTER SUBSCRIPTION regress_testsub SET (streaming = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -393,10 +393,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -409,18 +409,18 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (disable_on_error = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -433,10 +433,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -450,19 +450,19 @@ NOTICE: max_retention_duration is ineffective when retain_dead_tuples is disabl
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- ok
ALTER SUBSCRIPTION regress_testsub SET (max_retention_duration = 0);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -517,7 +517,167 @@ COMMIT;
-- ok, owning it is enough for this stuff
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG DESTINATION TESTS
+--
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+-- fail - unrecognized parameter value
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
+ERROR: unrecognized conflict_log_destination value: "invalid"
+HINT: Valid values are "log", "table", and "all".
+-- verify subconflictlogdest is 'log' and relid is 0 (InvalidOid) for default case
+CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
+ subname | subconflictlogdest | subconflictlogrelid
+------------------------------+--------------------+---------------------
+ regress_conflict_log_default | log | 0
+(1 row)
+
+-- verify empty string defaults to 'log'
+CREATE SUBSCRIPTION regress_conflict_empty_str CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = '');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
+ subname | subconflictlogdest | subconflictlogrelid
+----------------------------+--------------------+---------------------
+ regress_conflict_empty_str | log | 0
+(1 row)
+
+-- this should generate an internal table named pg_conflict_$subid$
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- check metadata in pg_subscription: destination should be 'table' and relid valid
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname | subconflictlogdest | has_relid
+------------------------+--------------------+-----------
+ regress_conflict_test1 | table | t
+(1 row)
+
+-- verify the physical table exists and its OID matches subconflictlogrelid
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND c.oid = s.subconflictlogrelid;
+ count
+-------
+ 1
+(1 row)
+
+-- check if the internal table has the correct schema (11 columns)
+SELECT count(*)
+FROM pg_attribute a
+JOIN pg_class c ON a.attrelid = c.oid
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0;
+ count
+-------
+ 11
+(1 row)
+
+--
+-- ALTER SUBSCRIPTION - conflict_log_destination state transitions
+--
+-- These tests verify the transition logic between different logging
+-- destinations, ensuring internal tables are created or dropped as expected.
+--
+-- transition from 'log' to 'all'
+-- a new internal conflict log table should be created
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
+-- verify metadata after ALTER (destination should be 'all')
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogdest | has_relid
+------------------------+--------------------+-----------
+ regress_conflict_test2 | all | t
+(1 row)
+
+-- transition from 'all' to 'table'
+-- should NOT drop the table, only change destination string
+SELECT subconflictlogrelid AS old_relid FROM pg_subscription WHERE subname = 'regress_conflict_test2' \gset
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'table');
+SELECT subconflictlogdest, subconflictlogrelid = :old_relid AS relid_unchanged
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subconflictlogdest | relid_unchanged
+--------------------+-----------------
+ table | t
+(1 row)
+
+-- transition from 'table' to 'log'
+-- should drop the table and clear relid
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log');
+SELECT subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subconflictlogdest | subconflictlogrelid
+--------------------+---------------------
+ log | 0
+(1 row)
+
+-- verify the physical table is gone
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test2';
+ count
+-------
+ 0
+(1 row)
+
+-- ensure drop table not allowed and DROP SUBSCRIPTION reaps the table
+-- re-enable table logging for verification
+ALTER SUBSCRIPTION regress_conflict_test1 SET (conflict_log_destination = 'table');
+-- fail - drop table not allowed due to internal dependency
+-- use DO block to hide OID in error message
+DO $$
+BEGIN
+ EXECUTE 'DROP TABLE ' || (SELECT 'pg_conflict.pg_conflict_' || oid FROM pg_subscription WHERE subname = 'regress_conflict_test1');
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege';
+END $$;
+NOTICE: captured expected error: insufficient_privilege
+-- PUBLICATION: Verify internal tables are not publishable
+-- pg_relation_is_publishable should return false for internal conflict log tables
+SELECT pg_relation_is_publishable(subconflictlogrelid)
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ pg_relation_is_publishable
+----------------------------
+ f
+(1 row)
+
+-- CLEANUP: Proper drop reaps the table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+-- Verify the table OID for reap check
+SELECT 'pg_conflict_' || oid AS internal_tablename FROM pg_subscription WHERE subname = 'regress_conflict_test1' \gset
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the internal table was reaped via dependency
+SELECT to_regclass(:'internal_tablename');
+ to_regclass
+-------------
+
+(1 row)
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_log_default DISABLE;
+ALTER SUBSCRIPTION regress_conflict_log_default SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_log_default;
+ALTER SUBSCRIPTION regress_conflict_empty_str DISABLE;
+ALTER SUBSCRIPTION regress_conflict_empty_str SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_empty_str;
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
RESET SESSION AUTHORIZATION;
+DROP SCHEMA clt;
+ERROR: schema "clt" does not exist
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
DROP ROLE regress_subscription_user3;
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index ef0c298d2df..6c7f358ffd2 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -365,7 +365,125 @@ COMMIT;
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG DESTINATION TESTS
+--
+
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+
+-- fail - unrecognized parameter value
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
+
+-- verify subconflictlogdest is 'log' and relid is 0 (InvalidOid) for default case
+CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
+
+-- verify empty string defaults to 'log'
+CREATE SUBSCRIPTION regress_conflict_empty_str CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = '');
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
+
+-- this should generate an internal table named pg_conflict_$subid$
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+
+-- check metadata in pg_subscription: destination should be 'table' and relid valid
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- verify the physical table exists and its OID matches subconflictlogrelid
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND c.oid = s.subconflictlogrelid;
+
+-- check if the internal table has the correct schema (11 columns)
+SELECT count(*)
+FROM pg_attribute a
+JOIN pg_class c ON a.attrelid = c.oid
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0;
+
+--
+-- ALTER SUBSCRIPTION - conflict_log_destination state transitions
+--
+-- These tests verify the transition logic between different logging
+-- destinations, ensuring internal tables are created or dropped as expected.
+--
+
+-- transition from 'log' to 'all'
+-- a new internal conflict log table should be created
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
+
+-- verify metadata after ALTER (destination should be 'all')
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'all' to 'table'
+-- should NOT drop the table, only change destination string
+SELECT subconflictlogrelid AS old_relid FROM pg_subscription WHERE subname = 'regress_conflict_test2' \gset
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'table');
+SELECT subconflictlogdest, subconflictlogrelid = :old_relid AS relid_unchanged
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'table' to 'log'
+-- should drop the table and clear relid
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log');
+SELECT subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- verify the physical table is gone
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test2';
+
+-- ensure drop table not allowed and DROP SUBSCRIPTION reaps the table
+-- re-enable table logging for verification
+ALTER SUBSCRIPTION regress_conflict_test1 SET (conflict_log_destination = 'table');
+
+-- fail - drop table not allowed due to internal dependency
+-- use DO block to hide OID in error message
+DO $$
+BEGIN
+ EXECUTE 'DROP TABLE ' || (SELECT 'pg_conflict.pg_conflict_' || oid FROM pg_subscription WHERE subname = 'regress_conflict_test1');
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege';
+END $$;
+
+-- PUBLICATION: Verify internal tables are not publishable
+-- pg_relation_is_publishable should return false for internal conflict log tables
+SELECT pg_relation_is_publishable(subconflictlogrelid)
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- CLEANUP: Proper drop reaps the table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+
+-- Verify the table OID for reap check
+SELECT 'pg_conflict_' || oid AS internal_tablename FROM pg_subscription WHERE subname = 'regress_conflict_test1' \gset
+
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the internal table was reaped via dependency
+SELECT to_regclass(:'internal_tablename');
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_log_default DISABLE;
+ALTER SUBSCRIPTION regress_conflict_log_default SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_log_default;
+
+ALTER SUBSCRIPTION regress_conflict_empty_str DISABLE;
+ALTER SUBSCRIPTION regress_conflict_empty_str SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_empty_str;
+
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+
RESET SESSION AUTHORIZATION;
+DROP SCHEMA clt;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
DROP ROLE regress_subscription_user3;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index b9e671fcda8..7e2410bf54e 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -500,6 +500,8 @@ ConditionVariableMinimallyPadded
ConditionalStack
ConfigData
ConfigVariable
+ConflictLogColumnDef
+ConflictLogDest
ConflictTupleInfo
ConflictType
ConnCacheEntry
--
2.43.0
[text/x-patch] v19-0002-Implement-the-conflict-insertion-infrastructure-.patch (33.0K, ../../CALDaNm2YOOdJ25X1sJ+DYz37K6Qi4g0ZNFHb_pQMF9UqancnEA@mail.gmail.com/3-v19-0002-Implement-the-conflict-insertion-infrastructure-.patch)
download | inline diff:
From ba128ff1de0f99f6f2ed113d821e20bb6e762acf Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Sat, 20 Dec 2025 15:20:09 +0530
Subject: [PATCH v19 2/6] Implement the conflict insertion infrastructure for
the conflict log table
This patch introduces the core logic to populate the conflict log table whenever
a logical replication conflict is detected. It captures the remote transaction
details along with the corresponding local state at the time of the conflict.
Handling Multi-row Conflicts: A single remote tuple may conflict with multiple
local tuples (e.g., in the case of multiple_unique_conflicts). To handle this,
the infrastructure creates a single row in the conflict log table for each
remote tuple. The details of all conflicting local rows are aggregated into a
single JSON array in the local_conflicts column.
The JSON array uses the following structured format:
[ { "xid": "1001", "commit_ts": "2025-12-25 10:00:00+05:30", "origin": "node_1",
"key": {"id": 1}, "tuple": {"id": 1, "val": "old_data"} }, ... ]
Example of querying the structured conflict data:
SELECT remote_xid, relname, remote_origin, local_conflicts[1] ->> 'xid' AS local_xid,
local_conflicts[1] ->> 'tuple' AS local_tuple
FROM myschema.conflict_log_history2;
remote_xid | relname | remote_origin | local_xid | local_tuple
------------+----------+---------------+-----------+---------------------
760 | test | pg_16406 | 771 | {"a":1,"b":10}
765 | conf_tab | pg_16406 | 775 | {"a":2,"b":2,"c":2}
---
src/backend/replication/logical/conflict.c | 548 +++++++++++++++++--
src/backend/replication/logical/launcher.c | 1 +
src/backend/replication/logical/worker.c | 31 +-
src/include/replication/conflict.h | 4 +-
src/include/replication/worker_internal.h | 7 +
src/test/subscription/t/037_conflict_dest.pl | 181 ++++++
6 files changed, 728 insertions(+), 44 deletions(-)
create mode 100644 src/test/subscription/t/037_conflict_dest.pl
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 93222ee3b88..e23ff0b70cf 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -15,13 +15,20 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/tableam.h"
+#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "pgstat.h"
#include "replication/conflict.h"
#include "replication/worker_internal.h"
#include "storage/lmgr.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/lsyscache.h"
+#include "utils/pg_lsn.h"
+#include "utils/jsonb.h"
static const char *const ConflictTypeNames[] = {
[CT_INSERT_EXISTS] = "insert_exists",
@@ -34,6 +41,19 @@ static const char *const ConflictTypeNames[] = {
[CT_MULTIPLE_UNIQUE_CONFLICTS] = "multiple_unique_conflicts"
};
+/* Schema for the elements within the 'local_conflicts' JSON array */
+static const ConflictLogColumnDef LocalConflictSchema[] =
+{
+ { .attname = "xid", .atttypid = XIDOID },
+ { .attname = "commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "origin", .atttypid = TEXTOID },
+ { .attname = "key", .atttypid = JSONOID },
+ { .attname = "tuple", .atttypid = JSONOID }
+};
+
+#define MAX_LOCAL_CONFLICT_INFO_ATTRS \
+ (sizeof(LocalConflictSchema) / sizeof(LocalConflictSchema[0]))
+
static int errcode_apply_conflict(ConflictType type);
static void errdetail_apply_conflict(EState *estate,
ResultRelInfo *relinfo,
@@ -50,8 +70,27 @@ static char *build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *localslot,
TupleTableSlot *remoteslot,
Oid indexoid);
+static void build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull);
static char *build_index_value_desc(EState *estate, Relation localrel,
TupleTableSlot *slot, Oid indexoid);
+static Datum tuple_table_slot_to_json_datum(TupleTableSlot *slot);
+static Datum tuple_table_slot_to_indextup_json(EState *estate,
+ Relation localrel,
+ Oid replica_index,
+ TupleTableSlot *slot);
+static TupleDesc build_conflict_tupledesc(void);
+static Datum build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples);
+static void prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot);
/*
* Get the xmin and commit timestamp data (origin and timestamp) associated
@@ -105,30 +144,83 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
ConflictType type, TupleTableSlot *searchslot,
TupleTableSlot *remoteslot, List *conflicttuples)
{
- Relation localrel = relinfo->ri_RelationDesc;
- StringInfoData err_detail;
+ Relation localrel = relinfo->ri_RelationDesc;
+ ConflictLogDest dest;
+ Relation conflictlogrel;
- initStringInfo(&err_detail);
+ /*
+ * Get both the conflict log destination and the opened conflict log
+ * relation for insertion.
+ */
+ conflictlogrel = GetConflictLogTableInfo(&dest);
- /* Form errdetail message by combining conflicting tuples information. */
- foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
- errdetail_apply_conflict(estate, relinfo, type, searchslot,
- conflicttuple->slot, remoteslot,
- conflicttuple->indexoid,
- conflicttuple->xmin,
- conflicttuple->origin,
- conflicttuple->ts,
- &err_detail);
+ /* Insert to table if destination is 'table' or 'all' */
+ if ((dest & CONFLICT_LOG_DEST_TABLE) != 0)
+ {
+ Assert(conflictlogrel != NULL);
+
+ /*
+ * Prepare the conflict log tuple. If the error level is below ERROR,
+ * insert it immediately. Otherwise, defer the insertion to a new
+ * transaction after the current one aborts, ensuring the insertion of
+ * the log tuple is not rolled back.
+ */
+ prepare_conflict_log_tuple(estate,
+ relinfo->ri_RelationDesc,
+ conflictlogrel,
+ type,
+ searchslot,
+ conflicttuples,
+ remoteslot);
+ if (elevel < ERROR)
+ InsertConflictLogTuple(conflictlogrel);
+
+ table_close(conflictlogrel, RowExclusiveLock);
+ }
pgstat_report_subscription_conflict(MySubscription->oid, type);
- ereport(elevel,
- errcode_apply_conflict(type),
- errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
- get_namespace_name(RelationGetNamespace(localrel)),
- RelationGetRelationName(localrel),
- ConflictTypeNames[type]),
- errdetail_internal("%s", err_detail.data));
+ /* Decide what detail to show in server logs. */
+ if ((dest & CONFLICT_LOG_DEST_LOG) != 0)
+ {
+ StringInfoData err_detail;
+
+ initStringInfo(&err_detail);
+
+ /* Form errdetail message by combining conflicting tuples information. */
+ foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
+ errdetail_apply_conflict(estate, relinfo, type, searchslot,
+ conflicttuple->slot, remoteslot,
+ conflicttuple->indexoid,
+ conflicttuple->xmin,
+ conflicttuple->origin,
+ conflicttuple->ts,
+ &err_detail);
+
+ /* Standard reporting with full internal details. */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail_internal("%s", err_detail.data));
+ }
+ else
+ {
+ /*
+ * 'table' only: Report the error msg but omit raw tuple data from
+ * server logs since it's already captured in the internal table.
+ */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail("Conflict details logged to internal table with OID %u.",
+ MySubscription->conflictlogrelid));
+ }
}
/*
@@ -162,6 +254,67 @@ InitConflictIndexes(ResultRelInfo *relInfo)
relInfo->ri_onConflictArbiterIndexes = uniqueIndexes;
}
+/*
+ * GetConflictLogTableInfo
+ *
+ * Fetches conflict logging metadata from the cached MySubscription pointer.
+ * Sets the destination enum in *log_dest and, if applicable, opens and
+ * returns the relation handle for the internal log table.
+ */
+Relation
+GetConflictLogTableInfo(ConflictLogDest *log_dest)
+{
+ Oid conflictlogrelid;
+ Relation conflictlogrel = NULL;
+
+ /*
+ * Convert the text log destination to the internal enum. MySubscription
+ * already contains the data from pg_subscription.
+ */
+ *log_dest = GetLogDestination(MySubscription->conflictlogdest);
+ conflictlogrelid = MySubscription->conflictlogrelid;
+
+ /* If destination is 'log' only, no table to open. */
+ if (*log_dest == CONFLICT_LOG_DEST_LOG)
+ return NULL;
+
+ Assert(OidIsValid(conflictlogrelid));
+
+ conflictlogrel = table_open(conflictlogrelid, RowExclusiveLock);
+
+ /* Conflict log table is dropped or not accessible. */
+ if (conflictlogrel == NULL)
+ ereport(WARNING,
+ (errcode(ERRCODE_UNDEFINED_TABLE),
+ errmsg("conflict log table with OID %u does not exist",
+ conflictlogrelid)));
+
+ return conflictlogrel;
+}
+
+/*
+ * InsertConflictLogTuple
+ *
+ * Insert conflict log tuple into the conflict log table. It uses
+ * HEAP_INSERT_NO_LOGICAL to explicitly block logical decoding of the tuple
+ * inserted into the conflict log table.
+ */
+void
+InsertConflictLogTuple(Relation conflictlogrel)
+{
+ int options = HEAP_INSERT_NO_LOGICAL;
+
+ /* A valid tuple must be prepared and stored in MyLogicalRepWorker. */
+ Assert(MyLogicalRepWorker->conflict_log_tuple != NULL);
+
+ heap_insert(conflictlogrel, MyLogicalRepWorker->conflict_log_tuple,
+ GetCurrentCommandId(true), options, NULL);
+
+ /* Free conflict log tuple. */
+ heap_freetuple(MyLogicalRepWorker->conflict_log_tuple);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+}
+
/*
* Add SQLSTATE error code to the current conflict report.
*/
@@ -472,6 +625,40 @@ build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
return tuple_value.data;
}
+/*
+ * Helper function to extract the "raw" index key Datums and their null flags
+ * from a TupleTableSlot, given an already open index descriptor.
+ * This is the reusable core logic.
+ */
+static void
+build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull)
+{
+ TupleTableSlot *tableslot = slot;
+
+ /*
+ * If the slot is a virtual slot, copy it into a heap tuple slot as
+ * FormIndexDatum only works with heap tuple slots.
+ */
+ if (TTS_IS_VIRTUAL(slot))
+ {
+ /* Slot is created within the EState's tuple table */
+ tableslot = table_slot_create(localrel, &estate->es_tupleTable);
+ tableslot = ExecCopySlot(tableslot, slot);
+ }
+
+ /*
+ * Initialize ecxt_scantuple for potential use in FormIndexDatum
+ */
+ GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+
+ /* Form the index datums */
+ FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values,
+ isnull);
+}
+
/*
* Helper functions to construct a string describing the contents of an index
* entry. See BuildIndexValueDescription for details.
@@ -487,41 +674,318 @@ build_index_value_desc(EState *estate, Relation localrel, TupleTableSlot *slot,
Relation indexDesc;
Datum values[INDEX_MAX_KEYS];
bool isnull[INDEX_MAX_KEYS];
- TupleTableSlot *tableslot = slot;
- if (!tableslot)
+ if (!slot)
return NULL;
Assert(CheckRelationOidLockedByMe(indexoid, RowExclusiveLock, true));
indexDesc = index_open(indexoid, NoLock);
- /*
- * If the slot is a virtual slot, copy it into a heap tuple slot as
- * FormIndexDatum only works with heap tuple slots.
- */
- if (TTS_IS_VIRTUAL(slot))
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+
+ index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+
+ index_close(indexDesc, NoLock);
+
+ return index_value;
+}
+
+/*
+ * tuple_table_slot_to_json_datum
+ *
+ * Helper function to convert a TupleTableSlot to Jsonb.
+ */
+static Datum
+tuple_table_slot_to_json_datum(TupleTableSlot *slot)
+{
+ HeapTuple tuple;
+ Datum datum;
+ Datum json;
+
+ Assert(slot != NULL);
+
+ tuple = ExecCopySlotHeapTuple(slot);
+ datum = heap_copy_tuple_as_datum(tuple, slot->tts_tupleDescriptor);
+
+ json = DirectFunctionCall1(row_to_json, datum);
+ heap_freetuple(tuple);
+
+ return json;
+}
+
+/*
+ * tuple_table_slot_to_indextup_json
+ *
+ * Fetch replica identity key from the tuple table slot and convert into a
+ * jsonb datum.
+ */
+static Datum
+tuple_table_slot_to_indextup_json(EState *estate, Relation localrel,
+ Oid indexid, TupleTableSlot *slot)
+{
+ Relation indexDesc;
+ Datum values[INDEX_MAX_KEYS];
+ bool isnull[INDEX_MAX_KEYS];
+ HeapTuple tuple;
+ TupleDesc tupdesc;
+ Datum datum;
+
+ Assert(slot != NULL);
+
+ Assert(CheckRelationOidLockedByMe(indexid, RowExclusiveLock, true));
+
+ indexDesc = index_open(indexid, NoLock);
+
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+ tupdesc = RelationGetDescr(indexDesc);
+
+ /* Bless the tupdesc so it can be looked up by row_to_json. */
+ BlessTupleDesc(tupdesc);
+
+ /* Form the replica identity tuple. */
+ tuple = heap_form_tuple(tupdesc, values, isnull);
+ datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ index_close(indexDesc, NoLock);
+
+ /* Convert to a JSONB datum. */
+ return DirectFunctionCall1(row_to_json, datum);
+}
+
+static TupleDesc
+build_conflict_tupledesc(void)
+{
+ TupleDesc tupdesc;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ for (int i = 0; i < MAX_LOCAL_CONFLICT_INFO_ATTRS; i++)
+ TupleDescInitEntry(tupdesc, (AttrNumber) (i + 1),
+ LocalConflictSchema[i].attname,
+ LocalConflictSchema[i].atttypid,
+ -1, 0);
+
+ BlessTupleDesc(tupdesc);
+
+ return tupdesc;
+}
+
+/*
+ * Builds the local conflicts JSONB array column from the list of
+ * ConflictTupleInfo objects.
+ *
+ * Example output structure:
+ * [ { "xid": "1001", "commit_ts": "...", "origin": "...", "tuple": {...} }, ... ]
+ */
+static Datum
+build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples)
+{
+ ListCell *lc;
+ List *json_datums = NIL; /* List to hold the row_to_json results (type json) */
+ Datum *json_datum_array;
+ bool *json_null_array;
+ Datum json_array_datum;
+ int num_conflicts;
+ int i;
+ int16 typlen;
+ bool typbyval;
+ char typalign;
+ TupleDesc tupdesc;
+
+ /* Build local conflicts tuple descriptor. */
+ tupdesc = build_conflict_tupledesc();
+
+ /* Process local conflict tuple list and prepare an array of JSON. */
+ foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
{
- tableslot = table_slot_create(localrel, &estate->es_tupleTable);
- tableslot = ExecCopySlot(tableslot, slot);
+ Datum values[MAX_LOCAL_CONFLICT_INFO_ATTRS] = {0};
+ bool nulls[MAX_LOCAL_CONFLICT_INFO_ATTRS] = {0};
+ char *origin_name = NULL;
+ HeapTuple tuple;
+ Datum json_datum;
+ int attno;
+
+ attno = 0;
+ values[attno++] = TransactionIdGetDatum(conflicttuple->xmin);
+
+ if (conflicttuple->ts)
+ values[attno++] = TimestampTzGetDatum(conflicttuple->ts);
+ else
+ nulls[attno++] = true;
+
+ if (conflicttuple->origin != InvalidRepOriginId)
+ replorigin_by_oid(conflicttuple->origin, true, &origin_name);
+
+ /* Store empty string if origin name for the tuple is NULL. */
+ if (origin_name != NULL)
+ values[attno++] = CStringGetTextDatum(origin_name);
+ else
+ nulls[attno++] = true;
+
+ /*
+ * Add the conflicting key values in the case of a unique constraint
+ * violation.
+ */
+ if (conflict_type == CT_INSERT_EXISTS ||
+ conflict_type == CT_UPDATE_EXISTS ||
+ conflict_type == CT_MULTIPLE_UNIQUE_CONFLICTS)
+ {
+ Oid indexoid = conflicttuple->indexoid;
+
+ Assert(OidIsValid(indexoid) && conflicttuple->slot &&
+ CheckRelationOidLockedByMe(indexoid, RowExclusiveLock,
+ true));
+ values[attno++] =
+ tuple_table_slot_to_indextup_json(estate, rel,
+ indexoid,
+ conflicttuple->slot);
+ }
+ else
+ nulls[attno++] = true;
+
+ /* Convert conflicting tuple to JSON datum. */
+ if (conflicttuple->slot)
+ values[attno] = tuple_table_slot_to_json_datum(conflicttuple->slot);
+ else
+ nulls[attno] = true;
+
+ Assert(attno + 1 == MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ tuple = heap_form_tuple(tupdesc, values, nulls);
+
+ json_datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ /*
+ * Build the higher level JSON datum in format described in function
+ * header.
+ */
+ json_datum = DirectFunctionCall1(row_to_json, json_datum);
+
+ /* Done with the temporary tuple. */
+ heap_freetuple(tuple);
+
+ /* Add to the array element. */
+ json_datums = lappend(json_datums, (void *) json_datum);
}
- /*
- * Initialize ecxt_scantuple for potential use in FormIndexDatum when
- * index expressions are present.
- */
- GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+ num_conflicts = list_length(json_datums);
- /*
- * The values/nulls arrays passed to BuildIndexValueDescription should be
- * the results of FormIndexDatum, which are the "raw" input to the index
- * AM.
- */
- FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values, isnull);
+ json_datum_array = (Datum *) palloc(num_conflicts * sizeof(Datum));
+ json_null_array = (bool *) palloc0(num_conflicts * sizeof(bool));
- index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+ i = 0;
+ foreach(lc, json_datums)
+ {
+ json_datum_array[i] = (Datum) lfirst(lc);
+ i++;
+ }
- index_close(indexDesc, NoLock);
+ /* Construct the json[] array Datum. */
+ get_typlenbyvalalign(JSONOID, &typlen, &typbyval, &typalign);
+ json_array_datum = PointerGetDatum(construct_array(json_datum_array,
+ num_conflicts,
+ JSONOID,
+ typlen,
+ typbyval,
+ typalign));
+ pfree(json_datum_array);
+ pfree(json_null_array);
+
+ return json_array_datum;
+}
- return index_value;
+/*
+ * prepare_conflict_log_tuple
+ *
+ * This routine prepares a tuple detailing a conflict encountered during
+ * logical replication. The prepared tuple will be stored in
+ * MyLogicalRepWorker->conflict_log_tuple which should be inserted into the
+ * conflict log table by calling InsertConflictLogTuple.
+ */
+static void
+prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot)
+{
+ Datum values[MAX_CONFLICT_ATTR_NUM] = {0};
+ bool nulls[MAX_CONFLICT_ATTR_NUM] = {0};
+ int attno;
+ char *remote_origin = NULL;
+ MemoryContext oldctx;
+
+ Assert(MyLogicalRepWorker->conflict_log_tuple == NULL);
+
+ /* Populate the values and nulls arrays. */
+ attno = 0;
+ values[attno++] = ObjectIdGetDatum(RelationGetRelid(rel));
+
+ values[attno++] =
+ CStringGetTextDatum(get_namespace_name(RelationGetNamespace(rel)));
+
+ values[attno++] = CStringGetTextDatum(RelationGetRelationName(rel));
+
+ values[attno++] = CStringGetTextDatum(ConflictTypeNames[conflict_type]);
+
+ if (TransactionIdIsValid(remote_xid))
+ values[attno++] = TransactionIdGetDatum(remote_xid);
+ else
+ nulls[attno++] = true;
+
+ values[attno++] = LSNGetDatum(remote_final_lsn);
+
+ if (remote_commit_ts > 0)
+ values[attno++] = TimestampTzGetDatum(remote_commit_ts);
+ else
+ nulls[attno++] = true;
+
+ if (replorigin_session_origin != InvalidRepOriginId)
+ replorigin_by_oid(replorigin_session_origin, true, &remote_origin);
+
+ if (remote_origin != NULL)
+ values[attno++] = CStringGetTextDatum(remote_origin);
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(searchslot))
+ {
+ Oid replica_index = GetRelationIdentityOrPK(rel);
+
+ /*
+ * If the table has a valid replica identity index, build the index
+ * json datum from key value. Otherwise, construct it from the complete
+ * tuple in REPLICA IDENTITY FULL cases.
+ */
+ if (OidIsValid(replica_index))
+ values[attno++] = tuple_table_slot_to_indextup_json(estate, rel,
+ replica_index,
+ searchslot);
+ else
+ values[attno++] = tuple_table_slot_to_json_datum(searchslot);
+ }
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(remoteslot))
+ values[attno++] = tuple_table_slot_to_json_datum(remoteslot);
+ else
+ nulls[attno++] = true;
+
+ values[attno] = build_local_conflicts_json_array(estate, rel,
+ conflict_type,
+ conflicttuples);
+
+ Assert(attno + 1 == MAX_CONFLICT_ATTR_NUM);
+
+ oldctx = MemoryContextSwitchTo(ApplyContext);
+ MyLogicalRepWorker->conflict_log_tuple =
+ heap_form_tuple(RelationGetDescr(conflictlogrel), values, nulls);
+ MemoryContextSwitchTo(oldctx);
}
diff --git a/src/backend/replication/logical/launcher.c b/src/backend/replication/logical/launcher.c
index 3ed86480be2..2dda5a44218 100644
--- a/src/backend/replication/logical/launcher.c
+++ b/src/backend/replication/logical/launcher.c
@@ -477,6 +477,7 @@ retry:
worker->oldest_nonremovable_xid = retain_dead_tuples
? MyReplicationSlot->data.xmin
: InvalidTransactionId;
+ worker->conflict_log_tuple = NULL;
worker->last_lsn = InvalidXLogRecPtr;
TIMESTAMP_NOBEGIN(worker->last_send_time);
TIMESTAMP_NOBEGIN(worker->last_recv_time);
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index ad281e7069b..5ac826c279f 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -482,7 +482,9 @@ static bool MySubscriptionValid = false;
static List *on_commit_wakeup_workers_subids = NIL;
bool in_remote_transaction = false;
-static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+TransactionId remote_xid = InvalidTransactionId;
+TimestampTz remote_commit_ts = 0;
/* fields valid only when processing streamed transaction */
static bool in_streamed_transaction = false;
@@ -1219,6 +1221,8 @@ apply_handle_begin(StringInfo s)
set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
remote_final_lsn = begin_data.final_lsn;
+ remote_commit_ts = begin_data.committime;
+ remote_xid = begin_data.xid;
maybe_start_skipping_changes(begin_data.final_lsn);
@@ -1745,6 +1749,10 @@ apply_handle_stream_start(StringInfo s)
/* extract XID of the top-level transaction */
stream_xid = logicalrep_read_stream_start(s, &first_segment);
+ remote_xid = stream_xid;
+ remote_final_lsn = InvalidXLogRecPtr;
+ remote_commit_ts = 0;
+
if (!TransactionIdIsValid(stream_xid))
ereport(ERROR,
(errcode(ERRCODE_PROTOCOL_VIOLATION),
@@ -5609,6 +5617,27 @@ start_apply(XLogRecPtr origin_startpos)
pgstat_report_subscription_error(MySubscription->oid,
MyLogicalRepWorker->type);
+ /*
+ * Insert any pending conflict log tuple under a new transaction.
+ */
+ if (MyLogicalRepWorker->conflict_log_tuple != NULL)
+ {
+ Relation conflictlogrel;
+ ConflictLogDest dest;
+
+ StartTransactionCommand();
+ PushActiveSnapshot(GetTransactionSnapshot());
+
+ /* Open conflict log table and insert the tuple. */
+ conflictlogrel = GetConflictLogTableInfo(&dest);
+ Assert((dest & CONFLICT_LOG_DEST_TABLE) != 0);
+ InsertConflictLogTuple(conflictlogrel);
+ table_close(conflictlogrel, RowExclusiveLock);
+
+ PopActiveSnapshot();
+ CommitTransactionCommand();
+ }
+
PG_RE_THROW();
}
}
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index af6deaa4297..b60c0b03e26 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -132,7 +132,6 @@ static const ConflictLogColumnDef ConflictLogSchema[] =
{ .attname = "local_conflicts", .atttypid = JSONARRAYOID }
};
-/* Define the count using the array size */
#define MAX_CONFLICT_ATTR_NUM (sizeof(ConflictLogSchema) / sizeof(ConflictLogSchema[0]))
extern bool GetTupleTransactionInfo(TupleTableSlot *localslot,
@@ -145,4 +144,7 @@ extern void ReportApplyConflict(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *remoteslot,
List *conflicttuples);
extern void InitConflictIndexes(ResultRelInfo *relInfo);
+extern Relation GetConflictLogTableInfo(ConflictLogDest *log_dest);
+extern void InsertConflictLogTuple(Relation conflictlogrel);
+extern bool ValidateConflictLogTable(Relation rel);
#endif
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index c1285fdd1bc..5bedfc5450f 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -101,6 +101,9 @@ typedef struct LogicalRepWorker
*/
TransactionId oldest_nonremovable_xid;
+ /* A conflict log tuple that is prepared but not yet inserted. */
+ HeapTuple conflict_log_tuple;
+
/* Stats. */
XLogRecPtr last_lsn;
TimestampTz last_send_time;
@@ -256,6 +259,10 @@ extern PGDLLIMPORT bool InitializingApplyWorker;
extern PGDLLIMPORT List *table_states_not_ready;
+extern XLogRecPtr remote_final_lsn;
+extern TimestampTz remote_commit_ts;
+extern TransactionId remote_xid;
+
extern void logicalrep_worker_attach(int slot);
extern LogicalRepWorker *logicalrep_worker_find(LogicalRepWorkerType wtype,
Oid subid, Oid relid,
diff --git a/src/test/subscription/t/037_conflict_dest.pl b/src/test/subscription/t/037_conflict_dest.pl
new file mode 100644
index 00000000000..d33993530a4
--- /dev/null
+++ b/src/test/subscription/t/037_conflict_dest.pl
@@ -0,0 +1,181 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+# Test conflicts in logical replication
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+###############################
+# Setup
+###############################
+
+# Create a publisher node
+my $node_publisher = PostgreSQL::Test::Cluster->new('publisher');
+$node_publisher->init(allows_streaming => 'logical');
+$node_publisher->start;
+
+# Create a subscriber node
+my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
+$node_subscriber->init(allows_streaming => 'logical');
+$node_subscriber->start;
+
+# Create a table on publisher
+$node_publisher->safe_psql('postgres',
+ "CREATE TABLE conf_tab (a int PRIMARY KEY, b int UNIQUE, c int UNIQUE);");
+
+$node_publisher->safe_psql('postgres',
+ "CREATE TABLE conf_tab_2 (a int PRIMARY KEY, b int UNIQUE, c int UNIQUE);"
+);
+
+# Create same table on subscriber
+$node_subscriber->safe_psql('postgres',
+ "CREATE TABLE conf_tab (a int PRIMARY key, b int UNIQUE, c int UNIQUE);");
+
+$node_subscriber->safe_psql(
+ 'postgres', qq[
+ CREATE TABLE conf_tab_2 (a int PRIMARY KEY, b int, c int, unique(a,b)) PARTITION BY RANGE (a);
+ CREATE TABLE conf_tab_2_p1 PARTITION OF conf_tab_2 FOR VALUES FROM (MINVALUE) TO (100);
+]);
+
+# Setup logical replication
+my $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
+$node_publisher->safe_psql('postgres',
+ "CREATE PUBLICATION pub_tab FOR TABLE conf_tab, conf_tab_2");
+
+# Create the subscription
+my $appname = 'sub_tab';
+$node_subscriber->safe_psql(
+ 'postgres',
+ "CREATE SUBSCRIPTION sub_tab
+ CONNECTION '$publisher_connstr application_name=$appname'
+ PUBLICATION pub_tab WITH (conflict_log_destination=table)");
+
+# Wait for initial table sync to finish
+$node_subscriber->wait_for_subscription_sync($node_publisher, $appname);
+
+##################################################
+# INSERT data on Pub and Sub
+##################################################
+
+# Insert data in the publisher table
+$node_publisher->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (1,1,1);");
+
+# Insert data in the subscriber table
+$node_subscriber->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (2,2,2), (3,3,3), (4,4,4);");
+
+###############################################################################
+# Test conflict insertion into the internal conflict log table
+###############################################################################
+
+$node_subscriber->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (10, 10, 10);");
+
+# Get the internally generated table name
+my $subid = $node_subscriber->safe_psql('postgres',
+ "SELECT oid FROM pg_subscription WHERE subname = 'sub_tab';");
+my $conflict_table = "pg_conflict.pg_conflict_$subid";
+
+$node_publisher->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (10, 20, 30);");
+
+# Wait for the conflict to be logged
+my $log_check = $node_subscriber->poll_query_until(
+ 'postgres',
+ "SELECT count(*) > 0 FROM $conflict_table;"
+);
+
+is($log_check, 1, 'Conflict was successfully logged to the internal table');
+
+my $json_query = qq[
+ SELECT string_agg((unnested.j::json)->'key'->>'a', ',')
+ FROM (
+ SELECT unnest(local_conflicts) AS j
+ FROM $conflict_table
+ ) AS unnested;
+];
+
+my $all_keys = $node_subscriber->safe_psql('postgres', $json_query);
+
+# Verify that '10' is present in the resulting string
+like($all_keys, qr/10/, 'Verified that key 10 exists in the local_conflicts log');
+
+pass('Conflict type and data successfully validated in internal table');
+
+# Final cleanup for subsequent bidirectional tests in the script
+$node_subscriber->safe_psql('postgres', "TRUNCATE conf_tab;");
+
+###############################################################################
+# Test Case: update_missing
+###############################################################################
+
+# Sync a row, then delete it locally on subscriber
+$node_publisher->safe_psql('postgres', "INSERT INTO conf_tab VALUES (50, 50, 50);");
+$node_publisher->wait_for_catchup($appname);
+$node_subscriber->safe_psql('postgres', "DELETE FROM conf_tab WHERE a = 50;");
+
+# Trigger conflict by updating that row on publisher
+$node_publisher->safe_psql('postgres', "UPDATE conf_tab SET b = 500 WHERE a = 50;");
+
+# Wait for the apply worker to detect the missing row and log it
+$node_subscriber->poll_query_until('postgres',
+ "SELECT count(*) > 0 FROM $conflict_table WHERE conflict_type = 'update_missing';"
+) or die "Timed out waiting for update_missing conflict";
+
+my $upd_miss_check = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM $conflict_table WHERE conflict_type = 'update_missing';");
+is($upd_miss_check, 1, 'Verified update_missing conflict logged to internal table');
+
+$node_subscriber->safe_psql('postgres', "TRUNCATE conf_tab;");
+
+###############################################################################
+# Test Case: insert_exists (via secondary unique index)
+###############################################################################
+
+# 1. Subscriber has a row with b=100
+$node_subscriber->safe_psql('postgres', "INSERT INTO conf_tab VALUES (100, 100, 100);");
+
+# 2. Publisher inserts a NEW PK (101) but a DUPLICATE 'b' (100)
+$node_publisher->safe_psql('postgres', "INSERT INTO conf_tab VALUES (101, 100, 101);");
+
+# 3. Verify it appears as 'insert_exists' in your log table
+$node_subscriber->poll_query_until('postgres',
+ "SELECT count(*) > 0 FROM $conflict_table WHERE conflict_type = 'insert_exists' AND local_conflicts::text LIKE '%100%';"
+) or die "Timed out waiting for secondary index insert_exists conflict";
+
+pass('Logged insert_exists triggered by secondary unique index violation');
+
+$node_subscriber->safe_psql('postgres', "TRUNCATE conf_tab;");
+
+###############################################################################
+# CASE 3: Switching Destination to 'log' (Server Log Verification)
+###############################################################################
+
+# Switch destination
+$node_subscriber->safe_psql('postgres',
+ "ALTER SUBSCRIPTION sub_tab SET (conflict_log_destination = 'all');");
+
+$node_subscriber->safe_psql('postgres', "DELETE FROM $conflict_table;");
+# Trigger a conflict for server log (insert_exists)
+$node_subscriber->safe_psql('postgres', "INSERT INTO conf_tab VALUES (600, 600, 600);");
+$node_publisher->safe_psql('postgres', "INSERT INTO conf_tab VALUES (600, 700, 700);");
+
+# Wait for table log
+$node_subscriber->poll_query_until('postgres', "SELECT count(*) > 0 FROM $conflict_table;")
+ or die "Timed out waiting for insert_exists conflict";
+
+# Check subscriber server log
+my $log_found = $node_subscriber->wait_for_log(
+ qr/conflict detected on relation "public.conf_tab": conflict=insert_exists/
+);
+ok($log_found, 'Conflict correctly directed to server stderr log');
+
+# Verify table count DID NOT increase for this conflict
+my $table_check = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM $conflict_table WHERE local_conflicts::text LIKE '%600%';");
+is($table_check, 1, 'Table log was bypassed when destination set to log');
+
+done_testing();
--
2.43.0
[text/x-patch] v19-0004-Add-shared-index-for-conflict-log-table-lookup-a.patch (14.1K, ../../CALDaNm2YOOdJ25X1sJ+DYz37K6Qi4g0ZNFHb_pQMF9UqancnEA@mail.gmail.com/4-v19-0004-Add-shared-index-for-conflict-log-table-lookup-a.patch)
download | inline diff:
From 50dc48b442e2c7683fb0e2948cec1e9b30f08c81 Mon Sep 17 00:00:00 2001
From: Vignesh C <vignesh21@gmail.com>
Date: Mon, 5 Jan 2026 15:46:10 +0530
Subject: [PATCH v19 4/6] Add shared index for conflict log table lookup and
allow explicit publication throuhg FOR TABLE publication
Introduce a dedicated shared unique index on
pg_subscription.subconflictlogrelid to make conflict log table
detection efficient, and index-backed. Previously, IsConflictLogTable()
relied on a full catalog scan of pg_subscription, which was inefficient.
This change adds pg_subscription_conflictrel_index and marks it as a
shared index, matching the shared pg_subscription table, and rewrites
conflict log table detection to use an indexed systable scan.
Additionally conflict tables can be replicated when the table is
explicitly specified through a FOR TABLE publication.
---
src/backend/catalog/catalog.c | 1 +
src/backend/catalog/pg_publication.c | 20 +--------------
src/backend/catalog/pg_subscription.c | 23 +++++++++++++++++
src/backend/commands/subscriptioncmds.c | 28 +++++++++------------
src/backend/replication/logical/conflict.c | 4 +--
src/backend/replication/pgoutput/pgoutput.c | 20 ++++++++++++---
src/bin/psql/describe.c | 4 ++-
src/include/catalog/pg_proc.dat | 7 ++++++
src/include/catalog/pg_subscription.h | 1 +
src/test/regress/expected/subscription.out | 7 +++---
src/test/regress/sql/subscription.sql | 5 ++--
11 files changed, 73 insertions(+), 47 deletions(-)
diff --git a/src/backend/catalog/catalog.c b/src/backend/catalog/catalog.c
index d438dc682ec..148a6ccf998 100644
--- a/src/backend/catalog/catalog.c
+++ b/src/backend/catalog/catalog.c
@@ -361,6 +361,7 @@ IsSharedRelation(Oid relationId)
relationId == SharedSecLabelObjectIndexId ||
relationId == SubscriptionNameIndexId ||
relationId == SubscriptionObjectIndexId ||
+ relationId == SubscriptionConflictrelIndexId ||
relationId == TablespaceNameIndexId ||
relationId == TablespaceOidIndexId)
return true;
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index cb383a5ce04..fcd48166edf 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -86,15 +86,6 @@ check_publication_add_relation(Relation targetrel)
errmsg("cannot add relation \"%s\" to publication",
RelationGetRelationName(targetrel)),
errdetail("This operation is not supported for unlogged tables.")));
-
- /* Can't be conflict log table */
- if (IsConflictLogTable(RelationGetRelid(targetrel)))
- ereport(ERROR,
- (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
- errmsg("cannot add relation \"%s.%s\" to publication",
- get_namespace_name(RelationGetNamespace(targetrel)),
- RelationGetRelationName(targetrel)),
- errdetail("This operation is not supported for conflict log tables.")));
}
/*
@@ -155,13 +146,6 @@ is_publishable_class(Oid relid, Form_pg_class reltuple)
/*
* Another variant of is_publishable_class(), taking a Relation.
- *
- * Note: Conflict log tables are not publishable. However, we intentionally
- * skip this check here because this function is called for every change and
- * performing this check during every change publication is costly. To ensure
- * unpublishable entries are ignored without incurring performance overhead,
- * tuples inserted into the conflict log table uses the HEAP_INSERT_NO_LOGICAL
- * flag. This allows the decoding layer to bypass these entries automatically.
*/
bool
is_publishable_relation(Relation rel)
@@ -187,9 +171,7 @@ pg_relation_is_publishable(PG_FUNCTION_ARGS)
if (!HeapTupleIsValid(tuple))
PG_RETURN_NULL();
- /* Subscription conflict log tables are not published */
- result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple)) &&
- !IsConflictLogTable(relid);
+ result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple));
ReleaseSysCache(tuple);
PG_RETURN_BOOL(result);
}
diff --git a/src/backend/catalog/pg_subscription.c b/src/backend/catalog/pg_subscription.c
index 285a598497d..1a93824504c 100644
--- a/src/backend/catalog/pg_subscription.c
+++ b/src/backend/catalog/pg_subscription.c
@@ -22,6 +22,7 @@
#include "catalog/pg_subscription.h"
#include "catalog/pg_subscription_rel.h"
#include "catalog/pg_type.h"
+#include "commands/subscriptioncmds.h"
#include "miscadmin.h"
#include "storage/lmgr.h"
#include "utils/array.h"
@@ -156,6 +157,28 @@ GetSubscription(Oid subid, bool missing_ok)
return sub;
}
+/*
+ * pg_relation_is_conflict_log_table
+ *
+ * Returns true if the given relation OID is used as a conflict log table
+ * by any subscription, else returns false.
+ */
+Datum
+pg_relation_is_conflict_log_table(PG_FUNCTION_ARGS)
+{
+ Oid relid = PG_GETARG_OID(0);
+ HeapTuple tuple;
+ bool result;
+
+ tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
+ if (!HeapTupleIsValid(tuple))
+ PG_RETURN_NULL();
+
+ result = IsConflictLogTable(relid);
+ ReleaseSysCache(tuple);
+ PG_RETURN_BOOL(result);
+}
+
/*
* Return number of subscriptions defined in given database.
* Used by dropdb() to check if database can indeed be dropped.
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index 4b2d98c9ed2..49c504960db 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -53,6 +53,7 @@
#include "storage/lmgr.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/guc.h"
#include "utils/lsyscache.h"
#include "utils/memutils.h"
@@ -3443,27 +3444,22 @@ bool
IsConflictLogTable(Oid relid)
{
Relation rel;
- TableScanDesc scan;
- HeapTuple tup;
- bool is_clt = false;
+ ScanKeyData scankey;
+ SysScanDesc scan;
+ bool is_clt;
rel = table_open(SubscriptionRelationId, AccessShareLock);
- scan = table_beginscan_catalog(rel, 0, NULL);
+ ScanKeyInit(&scankey,
+ Anum_pg_subscription_subconflictlogrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(relid));
- while (HeapTupleIsValid(tup = heap_getnext(scan, ForwardScanDirection)))
- {
- Form_pg_subscription subform = (Form_pg_subscription) GETSTRUCT(tup);
+ scan = systable_beginscan(rel, SubscriptionConflictrelIndexId,
+ true, NULL, 1, &scankey);
- /* Direct Oid comparison from catalog */
- if (OidIsValid(subform->subconflictlogrelid) &&
- subform->subconflictlogrelid == relid)
- {
- is_clt = true;
- break;
- }
- }
+ is_clt = HeapTupleIsValid(systable_getnext(scan));
- table_endscan(scan);
+ systable_endscan(scan);
table_close(rel, AccessShareLock);
return is_clt;
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index e23ff0b70cf..6fce652dbcb 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -302,13 +302,11 @@ GetConflictLogTableInfo(ConflictLogDest *log_dest)
void
InsertConflictLogTuple(Relation conflictlogrel)
{
- int options = HEAP_INSERT_NO_LOGICAL;
-
/* A valid tuple must be prepared and stored in MyLogicalRepWorker. */
Assert(MyLogicalRepWorker->conflict_log_tuple != NULL);
heap_insert(conflictlogrel, MyLogicalRepWorker->conflict_log_tuple,
- GetCurrentCommandId(true), options, NULL);
+ GetCurrentCommandId(true), 0, NULL);
/* Free conflict log tuple. */
heap_freetuple(MyLogicalRepWorker->conflict_log_tuple);
diff --git a/src/backend/replication/pgoutput/pgoutput.c b/src/backend/replication/pgoutput/pgoutput.c
index 458418a249d..7ab8c942652 100644
--- a/src/backend/replication/pgoutput/pgoutput.c
+++ b/src/backend/replication/pgoutput/pgoutput.c
@@ -2098,6 +2098,7 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
bool am_partition = get_rel_relispartition(relid);
char relkind = get_rel_relkind(relid);
List *rel_publications = NIL;
+ bool isconflictlogrel;
/* Reload publications if needed before use. */
if (!publications_valid)
@@ -2176,6 +2177,14 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
entry->estate = NULL;
memset(entry->exprstate, 0, sizeof(entry->exprstate));
+ /*
+ * Check whether this table is a conflict log table. If so, avoid
+ * publishing it via FOR ALL TABLES or FOR TABLES IN SCHEMA
+ * publications. However, they may still be published if explicitly
+ * added to a FOR TABLE publication for this table.
+ */
+ isconflictlogrel = IsConflictLogTable(relid);
+
/*
* Build publication cache. We can't use one provided by relcache as
* relcache considers all publications that the given relation is in,
@@ -2199,7 +2208,7 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
* If this is a FOR ALL TABLES publication, pick the partition
* root and set the ancestor level accordingly.
*/
- if (pub->alltables)
+ if (pub->alltables && !isconflictlogrel)
{
publish = true;
if (pub->pubviaroot && am_partition)
@@ -2225,8 +2234,12 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
{
Oid ancestor;
int level;
- List *ancestors = get_partition_ancestors(relid);
+ List *ancestors;
+
+ /* Conflict log table cannot be a partition */
+ Assert(isconflictlogrel == false);
+ ancestors = get_partition_ancestors(relid);
ancestor = GetTopMostAncestorInPublication(pub->oid,
ancestors,
&level);
@@ -2243,7 +2256,8 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
}
if (list_member_oid(pubids, pub->oid) ||
- list_member_oid(schemaPubids, pub->oid) ||
+ (list_member_oid(schemaPubids, pub->oid) &&
+ !isconflictlogrel) ||
ancestor_published)
publish = true;
}
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index 20f08e548ba..230a68892ae 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -3063,6 +3063,7 @@ describeOneTableDetails(const char *schemaname,
" JOIN pg_catalog.pg_publication_namespace pn ON p.oid = pn.pnpubid\n"
" JOIN pg_catalog.pg_class pc ON pc.relnamespace = pn.pnnspid\n"
"WHERE pc.oid ='%s' and pg_catalog.pg_relation_is_publishable('%s')\n"
+ "AND NOT pg_catalog.pg_relation_is_conflict_log_table('%s'::oid)\n"
"UNION\n"
"SELECT pubname\n"
" , pg_get_expr(pr.prqual, c.oid)\n"
@@ -3082,8 +3083,9 @@ describeOneTableDetails(const char *schemaname,
" , NULL\n"
"FROM pg_catalog.pg_publication p\n"
"WHERE p.puballtables AND pg_catalog.pg_relation_is_publishable('%s')\n"
+ "AND NOT pg_catalog.pg_relation_is_conflict_log_table('%s'::oid)\n"
"ORDER BY 1;",
- oid, oid, oid, oid);
+ oid, oid, oid, oid, oid, oid);
}
else
{
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 7f481687afe..d99f8500ac5 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12328,6 +12328,13 @@
prorettype => 'bool', proargtypes => 'regclass',
prosrc => 'pg_relation_is_publishable' },
+# subscriptions
+{ oid => '6123',
+ descr => 'returns whether a relation is a subscription conflict log table',
+ proname => 'pg_relation_is_conflict_log_table', provolatile => 's',
+ prorettype => 'bool', proargtypes => 'regclass',
+ prosrc => 'pg_relation_is_conflict_log_table' },
+
# rls
{ oid => '3298',
descr => 'row security for current context active on table by table oid',
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index 4aa29ea15d4..3d220b2db8a 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -123,6 +123,7 @@ DECLARE_TOAST_WITH_MACRO(pg_subscription, 4183, 4184, PgSubscriptionToastTable,
DECLARE_UNIQUE_INDEX_PKEY(pg_subscription_oid_index, 6114, SubscriptionObjectIndexId, pg_subscription, btree(oid oid_ops));
DECLARE_UNIQUE_INDEX(pg_subscription_subname_index, 6115, SubscriptionNameIndexId, pg_subscription, btree(subdbid oid_ops, subname name_ops));
+DECLARE_INDEX(pg_subscription_conflictrel_index, 6122, SubscriptionConflictrelIndexId, pg_subscription, btree(subconflictlogrelid oid_ops));
MAKE_SYSCACHE(SUBSCRIPTIONOID, pg_subscription_oid_index, 4);
MAKE_SYSCACHE(SUBSCRIPTIONNAME, pg_subscription_subname_index, 4);
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index d5f8abe9325..84abbaa5a4a 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -643,13 +643,14 @@ EXCEPTION WHEN insufficient_privilege THEN
RAISE NOTICE 'captured expected error: insufficient_privilege';
END $$;
NOTICE: captured expected error: insufficient_privilege
--- PUBLICATION: Verify internal tables are not publishable
--- pg_relation_is_publishable should return false for internal conflict log tables
+-- PUBLICATION: Verify internal tables are publishable
+-- pg_relation_is_publishable should return true for internal conflict log
+-- tables, as it can be published using TABLE publication.
SELECT pg_relation_is_publishable(subconflictlogrelid)
FROM pg_subscription WHERE subname = 'regress_conflict_test1';
pg_relation_is_publishable
----------------------------
- f
+ t
(1 row)
-- CLEANUP: Proper drop reaps the table
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index 6c7f358ffd2..83befa8722c 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -452,8 +452,9 @@ EXCEPTION WHEN insufficient_privilege THEN
RAISE NOTICE 'captured expected error: insufficient_privilege';
END $$;
--- PUBLICATION: Verify internal tables are not publishable
--- pg_relation_is_publishable should return false for internal conflict log tables
+-- PUBLICATION: Verify internal tables are publishable
+-- pg_relation_is_publishable should return true for internal conflict log
+-- tables, as it can be published using TABLE publication.
SELECT pg_relation_is_publishable(subconflictlogrelid)
FROM pg_subscription WHERE subname = 'regress_conflict_test1';
--
2.43.0
[text/x-patch] v19-0003-Doccumentation-patch.patch (11.3K, ../../CALDaNm2YOOdJ25X1sJ+DYz37K6Qi4g0ZNFHb_pQMF9UqancnEA@mail.gmail.com/5-v19-0003-Doccumentation-patch.patch)
download | inline diff:
From b1f2be1ef91231ba6ee4429ca73fbd666dcbcfc6 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Sun, 21 Dec 2025 18:51:57 +0530
Subject: [PATCH v19 3/6] Doccumentation patch
---
doc/src/sgml/logical-replication.sgml | 124 +++++++++++++++++++++-
doc/src/sgml/ref/alter_subscription.sgml | 14 ++-
doc/src/sgml/ref/create_subscription.sgml | 36 +++++++
3 files changed, 168 insertions(+), 6 deletions(-)
diff --git a/doc/src/sgml/logical-replication.sgml b/doc/src/sgml/logical-replication.sgml
index 58ce75d8b63..a2c66b164a0 100644
--- a/doc/src/sgml/logical-replication.sgml
+++ b/doc/src/sgml/logical-replication.sgml
@@ -289,6 +289,18 @@
option of <command>CREATE SUBSCRIPTION</command> for details.
</para>
+ <para>
+ Conflicts that occur during replication are, by default, logged as plain text
+ in the server log, which can make automated monitoring and analysis difficult.
+ The <command>CREATE SUBSCRIPTION</command> command provides the
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination">
+ <literal>conflict_log_destination</literal></link> option to record detailed
+ conflict information in a structured, queryable format. When this parameter
+ is set to <literal>table</literal> or <literal>all</literal>, the system
+ automatically manages a dedicated conflict storage table, which is created
+ and dropped along with the subscription. This significantly improves post-mortem
+ analysis and operational visibility of the replication setup.
+
<sect2 id="logical-replication-subscription-slot">
<title>Logical Replication Slot Management</title>
@@ -2006,9 +2018,15 @@ Publications:
</para>
<para>
- Additional logging is triggered, and the conflict statistics are collected (displayed in the
- <link linkend="monitoring-pg-stat-subscription-stats"><structname>pg_stat_subscription_stats</structname></link> view)
- in the following <firstterm>conflict</firstterm> cases:
+ Additional logging is triggered, and the conflict statistics are collected
+ (displayed in the <link linkend="monitoring-pg-stat-subscription-stats">
+ <structname>pg_stat_subscription_stats</structname></link> view)
+ in the following <firstterm>conflict</firstterm> cases. If the subscription
+ was created with the <literal>conflict_log_destination</literal> set to
+ <literal>table</literal> or <literal>all</literal>, detailed conflict
+ information is inserted into an internally managed table named
+ <literal>pg_conflict.pg_conflict_<replaceable>subscription_oid</replaceable>
+ </literal>, providing a structured record of all conflicts.
<variablelist>
<varlistentry id="conflict-insert-exists" xreflabel="insert_exists">
<term><literal>insert_exists</literal></term>
@@ -2118,7 +2136,96 @@ Publications:
</para>
<para>
- The log format for logical replication conflicts is as follows:
+ When the <literal>conflict_log_destination</literal> is set to
+ <literal>table</literal> or <literal>all</literal>, the system automatically
+ creates a new table with a predefined schema to log conflict details. This
+ table is created in the dedicated <literal>pg_conflict</literal> namespace.
+ The schema of this table is detailed in
+ <xref linkend="logical-replication-conflict-log-schema"/>.
+ </para>
+
+ <table id="logical-replication-conflict-log-schema">
+ <title>Conflict Log Table Schema</title>
+ <tgroup cols="3">
+ <thead>
+ <row>
+ <entry>Column</entry>
+ <entry>Type</entry>
+ <entry>Description</entry>
+ </row>
+ </thead>
+ <tbody>
+ <row>
+ <entry><literal>relid</literal></entry>
+ <entry><type>oid</type></entry>
+ <entry>The OID of the local table where the conflict occurred.</entry>
+ </row>
+ <row>
+ <entry><literal>schemaname</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The schema name of the conflicting table.</entry>
+ </row>
+ <row>
+ <entry><literal>relname</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The name of the conflicting table.</entry>
+ </row>
+ <row>
+ <entry><literal>conflict_type</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The type of conflict that occurred (e.g., <literal>insert_exists</literal>).</entry>
+ </row>
+ <row>
+ <entry><literal>remote_xid</literal></entry>
+ <entry><type>xid</type></entry>
+ <entry>The remote transaction ID that caused the conflict.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_commit_lsn</literal></entry>
+ <entry><type>pg_lsn</type></entry>
+ <entry>The final LSN of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_commit_ts</literal></entry>
+ <entry><type>timestamptz</type></entry>
+ <entry>The remote commit timestamp of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_origin</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The origin of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_tuple</literal></entry>
+ <entry><type>json</type></entry>
+ <entry>The JSON representation of the incoming remote row that caused the conflict.</entry>
+ </row>
+ <row>
+ <entry><literal>local_conflicts</literal></entry>
+ <entry><type>json[]</type></entry>
+ <entry>
+ An array of JSON objects representing the local state for each conflict attempt.
+ Each object includes the local transaction ID (<literal>xid</literal>), commit
+ timestamp (<literal>commit_ts</literal>), origin (<literal>origin</literal>),
+ conflicting key data (<literal>key</literal>), and the full local row
+ image (<literal>tuple</literal>).
+ </entry>
+ </row>
+ </tbody>
+ </tgroup>
+ </table>
+
+ <para>
+ The conflicting row data, including the original local tuple and
+ the remote tuple, is stored in <type>JSON</type> columns (<literal>local_tuple</literal>
+ and <literal>remote_tuple</literal>) for flexible querying and analysis.
+ </para>
+
+ <para>
+ If <literal>conflict_log_destination</literal> is left at the default
+ setting or explicitly configured as <literal>log</literal> or
+ <literal>all</literal>, logical replication conflicts are logged in the
+ following format:
<synopsis>
LOG: conflict detected on relation "<replaceable>schemaname</replaceable>.<replaceable>tablename</replaceable>": conflict=<replaceable>conflict_type</replaceable>
DETAIL: <replaceable class="parameter">detailed_explanation</replaceable>.
@@ -2412,6 +2519,15 @@ CONTEXT: processing remote data for replication origin "pg_16395" during "INSER
key or replica identity defined for it.
</para>
</listitem>
+
+ <listitem>
+ <para>
+ The internal table automatically created when <literal>conflict_log_destination</literal>
+ is set to <literal>table</literal> or <literal>all</literal> is excluded from
+ logical replication. It will not be published, even if a publication on the
+ subscriber is defined using <literal>FOR ALL TABLES</literal>.
+ </para>
+ </listitem>
</itemizedlist>
</sect1>
diff --git a/doc/src/sgml/ref/alter_subscription.sgml b/doc/src/sgml/ref/alter_subscription.sgml
index 27c06439f4f..90331f590e0 100644
--- a/doc/src/sgml/ref/alter_subscription.sgml
+++ b/doc/src/sgml/ref/alter_subscription.sgml
@@ -280,8 +280,9 @@ ALTER SUBSCRIPTION <replaceable class="parameter">name</replaceable> RENAME TO <
<link linkend="sql-createsubscription-params-with-origin"><literal>origin</literal></link>,
<link linkend="sql-createsubscription-params-with-failover"><literal>failover</literal></link>,
<link linkend="sql-createsubscription-params-with-two-phase"><literal>two_phase</literal></link>,
- <link linkend="sql-createsubscription-params-with-retain-dead-tuples"><literal>retain_dead_tuples</literal></link>, and
- <link linkend="sql-createsubscription-params-with-max-retention-duration"><literal>max_retention_duration</literal></link>.
+ <link linkend="sql-createsubscription-params-with-retain-dead-tuples"><literal>retain_dead_tuples</literal></link>,
+ <link linkend="sql-createsubscription-params-with-max-retention-duration"><literal>max_retention_duration</literal></link> and,
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>.
Only a superuser can set <literal>password_required = false</literal>.
</para>
@@ -339,6 +340,15 @@ ALTER SUBSCRIPTION <replaceable class="parameter">name</replaceable> RENAME TO <
<quote><literal>pg_conflict_detection</literal></quote>, created to retain
dead tuples for conflict detection, will be dropped.
</para>
+
+ <para>
+ When the <literal>conflict_log_destination</literal> parameter is set to
+ <literal>table</literal> or <literal>all</literal>, the system
+ automatically creates the internal logging table if it does not already
+ exist. Conversely, if the destination is changed to
+ <literal>log</literal>, logging to the table stops and the internal
+ table is automatically dropped.
+ </para>
</listitem>
</varlistentry>
diff --git a/doc/src/sgml/ref/create_subscription.sgml b/doc/src/sgml/ref/create_subscription.sgml
index b7dd361294b..f50bdb52f35 100644
--- a/doc/src/sgml/ref/create_subscription.sgml
+++ b/doc/src/sgml/ref/create_subscription.sgml
@@ -274,6 +274,42 @@ CREATE SUBSCRIPTION <replaceable class="parameter">subscription_name</replaceabl
</listitem>
</varlistentry>
+ <varlistentry id="sql-createsubscription-params-with-conflict-log-destination">
+ <term><literal>conflict_log_destination</literal> (<type>enum</type>)</term>
+ <listitem>
+ <para>
+ Specifies the destination for recording logical replication conflicts.
+ The supported values are <literal>log</literal>, <literal>table</literal>,
+ and <literal>all</literal>. The default is <literal>log</literal>.
+ </para>
+ <para>
+ The available destinations are:
+ <itemizedlist>
+ <listitem>
+ <para>
+ <literal>log</literal>: Conflict details are recorded in the server log.
+ This is the default behavior.
+ </para>
+ </listitem>
+ <listitem>
+ <para>
+ <literal>table</literal>: The system automatically creates a structured table
+ named <literal>pg_conflict.conflict_log_table_<subid></literal>.
+ This allows for easy querying and analysis of conflicts. This table is
+ automatically dropped when the subscription is removed.
+ </para>
+ </listitem>
+ <listitem>
+ <para>
+ <literal>all</literal>: Records the conflict information to both the server log
+ and the dedicated conflict table.
+ </para>
+ </listitem>
+ </itemizedlist>
+ </para>
+ </listitem>
+ </varlistentry>
+
<varlistentry id="sql-createsubscription-params-with-streaming">
<term><literal>streaming</literal> (<type>enum</type>)</term>
<listitem>
--
2.43.0
[text/x-patch] v19-0005-Preserve-conflict-log-destination-and-subscripti.patch (24.3K, ../../CALDaNm2YOOdJ25X1sJ+DYz37K6Qi4g0ZNFHb_pQMF9UqancnEA@mail.gmail.com/6-v19-0005-Preserve-conflict-log-destination-and-subscripti.patch)
download | inline diff:
From 15736f6690aea5114af5614bd4e871779546a457 Mon Sep 17 00:00:00 2001
From: Vignesh C <vignesh21@gmail.com>
Date: Mon, 5 Jan 2026 15:57:04 +0530
Subject: [PATCH v19 5/6] Preserve conflict log destination and subscription
OID for subscriptions
Support pg_dump to dump and restore the conflict_log_destination setting for
subscriptions.
During a normal CREATE SUBSCRIPTION, a conflict log table is created
automatically when required. However, during binary upgrade, the conflict
log table will already exist and must be reused rather than recreated, and
the subscription must retain its original OID to correctly re-establish
catalog relationships.
To ensure correct behavior, pg_dump now emits an ALTER SUBSCRIPTION command
after subscription creation to restore the conflict_log_destination setting.
---
src/backend/catalog/heap.c | 4 +-
src/backend/commands/subscriptioncmds.c | 144 +++++++++++++-----
src/backend/utils/adt/pg_upgrade_support.c | 10 ++
src/bin/pg_dump/pg_dump.c | 102 ++++++++++++-
src/bin/pg_dump/pg_dump.h | 2 +
src/bin/pg_dump/pg_dump_sort.c | 31 ++++
src/bin/pg_dump/t/002_pg_dump.pl | 5 +-
src/bin/pg_upgrade/pg_upgrade.c | 4 +
src/bin/pg_upgrade/t/004_subscription.pl | 14 +-
src/include/catalog/binary_upgrade.h | 1 +
src/include/catalog/pg_proc.dat | 4 +
.../expected/spgist_name_ops.out | 6 +-
12 files changed, 278 insertions(+), 49 deletions(-)
diff --git a/src/backend/catalog/heap.c b/src/backend/catalog/heap.c
index 10dadf378a4..3eca0b020f0 100644
--- a/src/backend/catalog/heap.c
+++ b/src/backend/catalog/heap.c
@@ -311,11 +311,13 @@ heap_create(const char *relname,
* But allow creating indexes on relations in pg_catalog even if
* allow_system_table_mods = off, upper layers already guarantee it's on a
* user defined relation, not a system one.
+ *
+ * Allow creation of conflict table in binary-upgrade mode.
*/
if (!allow_system_table_mods &&
((IsCatalogNamespace(relnamespace) && relkind != RELKIND_INDEX) ||
IsToastNamespace(relnamespace) ||
- IsConflictNamespace(relnamespace)) &&
+ (!IsBinaryUpgrade && IsConflictNamespace(relnamespace))) &&
IsNormalProcessingMode())
ereport(ERROR,
(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index 49c504960db..6c865dacfd7 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -88,6 +88,11 @@
/* check if the 'val' has 'bits' set */
#define IsSet(val, bits) (((val) & (bits)) == (bits))
+/*
+ * This will be set by the pg_upgrade_support function --
+ * binary_upgrade_set_next_pg_subscription_oid().
+ */
+Oid binary_upgrade_next_pg_subscription_oid = InvalidOid;
/*
* Structure to hold a bitmap representing the user-provided CREATE/ALTER
* SUBSCRIPTION command options and the parsed/default values of each of them.
@@ -735,8 +740,21 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
memset(values, 0, sizeof(values));
memset(nulls, false, sizeof(nulls));
- subid = GetNewOidWithIndex(rel, SubscriptionObjectIndexId,
- Anum_pg_subscription_oid);
+ /* Use binary-upgrade override for pg_subscription.oid? */
+ if (IsBinaryUpgrade)
+ {
+ if (!OidIsValid(binary_upgrade_next_pg_subscription_oid))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("pg_subscription OID value not set when in binary upgrade mode")));
+
+ subid = binary_upgrade_next_pg_subscription_oid;
+ binary_upgrade_next_pg_subscription_oid = InvalidOid;
+ }
+ else
+ subid = GetNewOidWithIndex(rel, SubscriptionObjectIndexId,
+ Anum_pg_subscription_oid);
+
values[Anum_pg_subscription_oid - 1] = ObjectIdGetDatum(subid);
values[Anum_pg_subscription_subdbid - 1] = ObjectIdGetDatum(MyDatabaseId);
values[Anum_pg_subscription_subskiplsn - 1] = LSNGetDatum(InvalidXLogRecPtr);
@@ -1378,6 +1396,84 @@ CheckAlterSubOption(Subscription *sub, const char *option,
}
}
+/*
+ * AlterSubscriptionConflictLogDestination
+ *
+ * Update the conflict log table associated with a subscription when its
+ * conflict log destination is changed.
+ *
+ * If the new destination requires a conflict log table and none was previously
+ * required, this function validates an existing conflict log table identified
+ * by the subscription specific naming convention or creates a new one.
+ *
+ * If the new destination no longer requires a conflict log table, the existing
+ * conflict log table associated with the subscription is removed via internal
+ * dependency cleanup to prevent orphaned relations.
+ *
+ * The function enforces that any conflict log table used is a permanent
+ * relation in a permanent schema, matches the expected structure, and is not
+ * already associated with another subscription.
+ *
+ * On success, *conflicttablerelid is set to the OID of the conflict log table
+ * that was created or validated, or to InvalidOid if no table is required.
+ *
+ * Returns true if the subscription's conflict log table reference must be
+ * updated as a result of the destination change; false otherwise.
+ */
+static bool
+AlterSubscriptionConflictLogDestination(Subscription *sub,
+ ConflictLogDest logdest,
+ Oid *conflicttablerelid)
+{
+ ConflictLogDest old_dest = GetLogDestination(sub->conflictlogdest);
+ bool want_table;
+ bool has_oldtable;
+ bool update_relid = false;
+ Oid relid = InvalidOid;
+
+ want_table = IsSet(logdest, CONFLICT_LOG_DEST_TABLE);
+ has_oldtable = IsSet(old_dest, CONFLICT_LOG_DEST_TABLE);
+
+ if (want_table && !has_oldtable)
+ {
+ char relname[NAMEDATALEN];
+
+ snprintf(relname, NAMEDATALEN, "pg_conflict_%u", sub->oid);
+
+ /*
+ * In upgrade scenarios, the conflict log table already exists. Update
+ * the catalog to record the association.
+ */
+ relid = get_relname_relid(relname, PG_CONFLICT_NAMESPACE);
+ if (!OidIsValid(relid))
+ relid = create_conflict_log_table(sub->oid, sub->name);
+
+ update_relid = true;
+ }
+ else if (!want_table && has_oldtable)
+ {
+ ObjectAddress object;
+
+ /*
+ * Conflict log tables are recorded as internal dependencies of the
+ * subscription. Drop the table if it is not required anymore to
+ * avoid stale or orphaned relations.
+ *
+ * XXX: At present, only conflict log tables are managed this way. In
+ * future if we introduce additional internal dependencies, we may
+ * need a targeted deletion to avoid deletion of any other objects.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId, sub->oid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+ update_relid = true;
+ }
+
+ *conflicttablerelid = relid;
+ return update_relid;
+}
+
/*
* Alter the existing subscription.
*/
@@ -1725,52 +1821,20 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
if (opts.logdest != old_dest)
{
- bool want_table =
- IsSet(opts.logdest, CONFLICT_LOG_DEST_TABLE);
- bool has_oldtable =
- IsSet(old_dest, CONFLICT_LOG_DEST_TABLE);
+ bool update_relid;
+ Oid relid = InvalidOid;
values[Anum_pg_subscription_subconflictlogdest - 1] =
CStringGetTextDatum(ConflictLogDestNames[opts.logdest]);
replaces[Anum_pg_subscription_subconflictlogdest - 1] = true;
- if (want_table && !has_oldtable)
+ update_relid = AlterSubscriptionConflictLogDestination(sub, opts.logdest, &relid);
+ if (update_relid)
{
- Oid relid;
-
- relid = create_conflict_log_table(subid, sub->name);
-
- values[Anum_pg_subscription_subconflictlogrelid - 1] =
- ObjectIdGetDatum(relid);
- replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
- true;
- }
- else if (!want_table && has_oldtable)
- {
- ObjectAddress object;
-
- /*
- * Conflict log tables are recorded as internal
- * dependencies of the subscription. Drop the
- * table if it is not required anymore to avoid
- * stale or orphaned relations.
- *
- * XXX: At present, only conflict log tables are
- * managed this way. In future if we introduce
- * additional internal dependencies, we may need
- * a targeted deletion to avoid deletion of any
- * other objects.
- */
- ObjectAddressSet(object, SubscriptionRelationId,
- subid);
- performDeletion(&object, DROP_CASCADE,
- PERFORM_DELETION_INTERNAL |
- PERFORM_DELETION_SKIP_ORIGINAL);
-
values[Anum_pg_subscription_subconflictlogrelid - 1] =
- ObjectIdGetDatum(InvalidOid);
+ ObjectIdGetDatum(relid);
replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
- true;
+ true;
}
}
}
diff --git a/src/backend/utils/adt/pg_upgrade_support.c b/src/backend/utils/adt/pg_upgrade_support.c
index 8953a17753e..638130b7305 100644
--- a/src/backend/utils/adt/pg_upgrade_support.c
+++ b/src/backend/utils/adt/pg_upgrade_support.c
@@ -181,6 +181,16 @@ binary_upgrade_set_next_pg_authid_oid(PG_FUNCTION_ARGS)
PG_RETURN_VOID();
}
+Datum
+binary_upgrade_set_next_pg_subscription_oid(PG_FUNCTION_ARGS)
+{
+ Oid subid = PG_GETARG_OID(0);
+
+ CHECK_IS_BINARY_UPGRADE;
+ binary_upgrade_next_pg_subscription_oid = subid;
+ PG_RETURN_VOID();
+}
+
Datum
binary_upgrade_create_empty_extension(PG_FUNCTION_ARGS)
{
diff --git a/src/bin/pg_dump/pg_dump.c b/src/bin/pg_dump/pg_dump.c
index 7df56d8b1b0..44cc9f507a4 100644
--- a/src/bin/pg_dump/pg_dump.c
+++ b/src/bin/pg_dump/pg_dump.c
@@ -1997,6 +1997,8 @@ checkExtensionMembership(DumpableObject *dobj, Archive *fout)
static void
selectDumpableNamespace(NamespaceInfo *nsinfo, Archive *fout)
{
+ DumpOptions *dopt = fout->dopt;
+
/*
* DUMP_COMPONENT_DEFINITION typically implies a CREATE SCHEMA statement
* and (for --clean) a DROP SCHEMA statement. (In the absence of
@@ -2026,6 +2028,32 @@ selectDumpableNamespace(NamespaceInfo *nsinfo, Archive *fout)
*/
nsinfo->dobj.dump_contains = nsinfo->dobj.dump = DUMP_COMPONENT_ACL;
}
+ else if (strcmp(nsinfo->dobj.name, "pg_conflict") == 0)
+ {
+ if (dopt->binary_upgrade)
+ {
+ /*
+ * The pg_conflict schema is a strange beast that sits in a sort of
+ * no-mans-land between being a system object and a user object.
+ * CREATE SCHEMA would fail, so its DUMP_COMPONENT_DEFINITION is
+ * just a comment.
+ */
+ nsinfo->create = false;
+ nsinfo->dobj.dump = DUMP_COMPONENT_ALL;
+ nsinfo->dobj.dump &= ~DUMP_COMPONENT_DEFINITION;
+ nsinfo->dobj.dump_contains = DUMP_COMPONENT_ALL;
+
+ /*
+ * Also, make like it has a comment even if it doesn't; this is so
+ * that we'll emit a command to drop the comment, if appropriate.
+ * (Without this, we'd not call dumpCommentExtended for it.)
+ */
+ nsinfo->dobj.components |= DUMP_COMPONENT_COMMENT;
+ }
+ else
+ nsinfo->dobj.dump_contains = nsinfo->dobj.dump =
+ DUMP_COMPONENT_NONE;
+ }
else if (strncmp(nsinfo->dobj.name, "pg_", 3) == 0 ||
strcmp(nsinfo->dobj.name, "information_schema") == 0)
{
@@ -2083,9 +2111,31 @@ selectDumpableNamespace(NamespaceInfo *nsinfo, Archive *fout)
static void
selectDumpableTable(TableInfo *tbinfo, Archive *fout)
{
+ DumpOptions *dopt = fout->dopt;
+
if (checkExtensionMembership(&tbinfo->dobj, fout))
return; /* extension membership overrides all else */
+ if (strcmp(tbinfo->dobj.namespace->dobj.name, "pg_conflict") == 0)
+ {
+ if (dopt->binary_upgrade)
+ {
+ /*
+ * Dump pg_conflict tables only during binary upgrade.
+ * The schema is assumed to already exist.
+ */
+ tbinfo->dobj.dump = DUMP_COMPONENT_DEFINITION;
+
+ /*
+ * Suppress the "ALTER TABLE ... OWNER TO ..." command for this
+ * table. This prevents pg_dump from outputting the owner change.
+ */
+ tbinfo->rolname = NULL;
+ }
+ else
+ tbinfo->dobj.dump = DUMP_COMPONENT_NONE;
+ }
+
/*
* If specific tables are being dumped, dump just those tables; else, dump
* according to the parent namespace's dump flag.
@@ -5130,6 +5180,8 @@ getSubscriptions(Archive *fout)
int i_subfailover;
int i_subretaindeadtuples;
int i_submaxretention;
+ int i_subconflictlogrelid;
+ int i_sublogdestination;
int i,
ntups;
@@ -5216,10 +5268,17 @@ getSubscriptions(Archive *fout)
if (fout->remoteVersion >= 190000)
appendPQExpBufferStr(query,
- " s.submaxretention\n");
+ " s.submaxretention,\n");
else
appendPQExpBuffer(query,
- " 0 AS submaxretention\n");
+ " 0 AS submaxretention,\n");
+
+ if (fout->remoteVersion >= 190000)
+ appendPQExpBufferStr(query,
+ " s.subconflictlogrelid, s.subconflictlogdest\n");
+ else
+ appendPQExpBufferStr(query,
+ " NULL AS subconflictlogrelid, NULL AS subconflictlogdest\n");
appendPQExpBufferStr(query,
"FROM pg_subscription s\n");
@@ -5261,6 +5320,8 @@ getSubscriptions(Archive *fout)
i_subpublications = PQfnumber(res, "subpublications");
i_suborigin = PQfnumber(res, "suborigin");
i_suboriginremotelsn = PQfnumber(res, "suboriginremotelsn");
+ i_subconflictlogrelid = PQfnumber(res, "subconflictlogrelid");
+ i_sublogdestination = PQfnumber(res, "subconflictlogdest");
subinfo = pg_malloc(ntups * sizeof(SubscriptionInfo));
@@ -5309,6 +5370,30 @@ getSubscriptions(Archive *fout)
else
subinfo[i].suboriginremotelsn =
pg_strdup(PQgetvalue(res, i, i_suboriginremotelsn));
+ if (PQgetisnull(res, i, i_subconflictlogrelid))
+ subinfo[i].subconflictlogrelid = InvalidOid;
+ else
+ {
+ TableInfo *tableInfo;
+
+ subinfo[i].subconflictlogrelid =
+ atooid(PQgetvalue(res, i, i_subconflictlogrelid));
+
+ if (subinfo[i].subconflictlogrelid)
+ {
+ tableInfo = findTableByOid(subinfo[i].subconflictlogrelid);
+ if (!tableInfo)
+ pg_fatal("could not find conflict log table with OID %u",
+ subinfo[i].subconflictlogrelid);
+
+ addObjectDependency(&subinfo[i].dobj, tableInfo->dobj.dumpId);
+ }
+ }
+ if (PQgetisnull(res, i, i_sublogdestination))
+ subinfo[i].subconflictlogdest = NULL;
+ else
+ subinfo[i].subconflictlogdest =
+ pg_strdup(PQgetvalue(res, i, i_sublogdestination));
/* Decide whether we want to dump it */
selectDumpableObject(&(subinfo[i].dobj), fout);
@@ -5502,6 +5587,14 @@ dumpSubscription(Archive *fout, const SubscriptionInfo *subinfo)
appendPQExpBuffer(delq, "DROP SUBSCRIPTION %s;\n",
qsubname);
+ if (dopt->binary_upgrade)
+ {
+ appendPQExpBufferStr(query, "\n-- For binary upgrade, must preserve pg_subscription.oid\n");
+ appendPQExpBuffer(query,
+ "SELECT pg_catalog.binary_upgrade_set_next_pg_subscription_oid('%u'::pg_catalog.oid);\n\n",
+ subinfo->dobj.catId.oid);
+ }
+
appendPQExpBuffer(query, "CREATE SUBSCRIPTION %s CONNECTION ",
qsubname);
appendStringLiteralAH(query, subinfo->subconninfo, fout);
@@ -5564,6 +5657,11 @@ dumpSubscription(Archive *fout, const SubscriptionInfo *subinfo)
appendPQExpBufferStr(query, ");\n");
+ appendPQExpBuffer(query,
+ "\n\nALTER SUBSCRIPTION %s SET (conflict_log_destination = %s);\n",
+ qsubname,
+ subinfo->subconflictlogdest);
+
/*
* In binary-upgrade mode, we allow the replication to continue after the
* upgrade.
diff --git a/src/bin/pg_dump/pg_dump.h b/src/bin/pg_dump/pg_dump.h
index 4c4b14e5fc7..6485166f2c6 100644
--- a/src/bin/pg_dump/pg_dump.h
+++ b/src/bin/pg_dump/pg_dump.h
@@ -719,12 +719,14 @@ typedef struct _SubscriptionInfo
bool subfailover;
bool subretaindeadtuples;
int submaxretention;
+ Oid subconflictlogrelid;
char *subconninfo;
char *subslotname;
char *subsynccommit;
char *subpublications;
char *suborigin;
char *suboriginremotelsn;
+ char *subconflictlogdest;
} SubscriptionInfo;
/*
diff --git a/src/bin/pg_dump/pg_dump_sort.c b/src/bin/pg_dump/pg_dump_sort.c
index 24bed6681de..a1d7765eb75 100644
--- a/src/bin/pg_dump/pg_dump_sort.c
+++ b/src/bin/pg_dump/pg_dump_sort.c
@@ -1131,6 +1131,19 @@ repairTableAttrDefMultiLoop(DumpableObject *tableobj,
addObjectDependency(attrdefobj, tableobj->dumpId);
}
+/*
+ * Because we make subscriptions depend on their conflict log tables, while
+ * there is an automatic dependency in the other direction, we need to break
+ * the loop. Remove the automatic dependency, allowing the table to be created
+ * first.
+ */
+static void
+repairSubscriptionTableLoop(DumpableObject *subobj, DumpableObject *tableobj)
+{
+ /* Remove table's dependency on subscription */
+ removeObjectDependency(tableobj, subobj->dumpId);
+}
+
/*
* CHECK, NOT NULL constraints on domains work just like those on tables ...
*/
@@ -1361,6 +1374,24 @@ repairDependencyLoop(DumpableObject **loop,
return;
}
+ /*
+ * Subscription and its Conflict Log Table
+ */
+ if (nLoop == 2 &&
+ loop[0]->objType == DO_TABLE &&
+ loop[1]->objType == DO_SUBSCRIPTION)
+ {
+ repairSubscriptionTableLoop(loop[1], loop[0]);
+ return;
+ }
+ if (nLoop == 2 &&
+ loop[0]->objType == DO_SUBSCRIPTION &&
+ loop[1]->objType == DO_TABLE)
+ {
+ repairSubscriptionTableLoop(loop[0], loop[1]);
+ return;
+ }
+
/* index on partitioned table and corresponding index on partition */
if (nLoop == 2 &&
loop[0]->objType == DO_INDEX &&
diff --git a/src/bin/pg_dump/t/002_pg_dump.pl b/src/bin/pg_dump/t/002_pg_dump.pl
index 28812d28aa9..0df84cd5897 100644
--- a/src/bin/pg_dump/t/002_pg_dump.pl
+++ b/src/bin/pg_dump/t/002_pg_dump.pl
@@ -3204,9 +3204,10 @@ my %tests = (
create_order => 50,
create_sql => 'CREATE SUBSCRIPTION sub3
CONNECTION \'dbname=doesnotexist\' PUBLICATION pub1
- WITH (connect = false, origin = any, streaming = on);',
+ WITH (connect = false, origin = any, streaming = on, conflict_log_destination= table);',
regexp => qr/^
- \QCREATE SUBSCRIPTION sub3 CONNECTION 'dbname=doesnotexist' PUBLICATION pub1 WITH (connect = false, slot_name = 'sub3', streaming = on);\E
+ \QCREATE SUBSCRIPTION sub3 CONNECTION 'dbname=doesnotexist' PUBLICATION pub1 WITH (connect = false, slot_name = 'sub3', streaming = on);\E\n\n\n
+ \QALTER SUBSCRIPTION sub3 SET (conflict_log_destination = table);\E
/xm,
like => { %full_runs, section_post_data => 1, },
unlike => {
diff --git a/src/bin/pg_upgrade/pg_upgrade.c b/src/bin/pg_upgrade/pg_upgrade.c
index 2127d297bfe..135ef658c2c 100644
--- a/src/bin/pg_upgrade/pg_upgrade.c
+++ b/src/bin/pg_upgrade/pg_upgrade.c
@@ -35,6 +35,10 @@
*
* We control all assignments of pg_database.oid because we want the directory
* names to match between the old and new cluster.
+ *
+ * We control assignment of pg_subscription.oid because we want the oid to
+ * match between the old and new cluster to make use of subscription's
+ * conflict log table which is named using the subscription oid.
*/
diff --git a/src/bin/pg_upgrade/t/004_subscription.pl b/src/bin/pg_upgrade/t/004_subscription.pl
index 3a8c8b88976..00c4f9a9fc1 100644
--- a/src/bin/pg_upgrade/t/004_subscription.pl
+++ b/src/bin/pg_upgrade/t/004_subscription.pl
@@ -290,7 +290,7 @@ $publisher->safe_psql(
$old_sub->safe_psql(
'postgres', qq[
CREATE TABLE tab_upgraded2(id int);
- CREATE SUBSCRIPTION regress_sub5 CONNECTION '$connstr' PUBLICATION regress_pub5;
+ CREATE SUBSCRIPTION regress_sub5 CONNECTION '$connstr' PUBLICATION regress_pub5 with (conflict_log_destination = 'table');
]);
# The table tab_upgraded2 will be in the init state as the subscriber's
@@ -312,7 +312,10 @@ my $tab_upgraded1_oid = $old_sub->safe_psql('postgres',
"SELECT oid FROM pg_class WHERE relname = 'tab_upgraded1'");
my $tab_upgraded2_oid = $old_sub->safe_psql('postgres',
"SELECT oid FROM pg_class WHERE relname = 'tab_upgraded2'");
-
+my $sub5_oid = $old_sub->safe_psql('postgres',
+ "SELECT oid FROM pg_subscription where subname = 'regress_sub5'");
+my $sub_clt_relid = $old_sub->safe_psql('postgres',
+ "SELECT subconflictlogrelid FROM pg_subscription WHERE subname = 'regress_sub5'");
$old_sub->stop;
# Change configuration so that initial table sync does not get started
@@ -393,6 +396,13 @@ $result = $new_sub->safe_psql('postgres',
"SELECT xmin IS NOT NULL from pg_replication_slots WHERE slot_name = 'pg_conflict_detection'");
is($result, qq(t), "conflict detection slot exists");
+# The subscription oid and the subscription conflict log table relid should be preserved
+$result = $new_sub->safe_psql('postgres', "SELECT oid FROM pg_subscription WHERE subname = 'regress_sub5'");
+is($result, qq($sub5_oid), "subscription oid should have been preserved");
+
+$result = $new_sub->safe_psql('postgres', "SELECT subconflictlogrelid FROM pg_subscription WHERE subname = 'regress_sub5'");
+is($result, qq($sub_clt_relid), "subscription conflict log table relid should have been preserved");
+
# Resume the initial sync and wait until all tables of subscription
# 'regress_sub5' are synchronized
$new_sub->append_conf('postgresql.conf',
diff --git a/src/include/catalog/binary_upgrade.h b/src/include/catalog/binary_upgrade.h
index 7bf7ae44385..b15b18e7dc9 100644
--- a/src/include/catalog/binary_upgrade.h
+++ b/src/include/catalog/binary_upgrade.h
@@ -32,6 +32,7 @@ extern PGDLLIMPORT RelFileNumber binary_upgrade_next_toast_pg_class_relfilenumbe
extern PGDLLIMPORT Oid binary_upgrade_next_pg_enum_oid;
extern PGDLLIMPORT Oid binary_upgrade_next_pg_authid_oid;
+extern PGDLLIMPORT Oid binary_upgrade_next_pg_subscription_oid;
extern PGDLLIMPORT bool binary_upgrade_record_init_privs;
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index d99f8500ac5..8789d03261c 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -11840,6 +11840,10 @@
proname => 'binary_upgrade_create_conflict_detection_slot', proisstrict => 'f',
provolatile => 'v', proparallel => 'u', prorettype => 'void',
proargtypes => '', prosrc => 'binary_upgrade_create_conflict_detection_slot' },
+{ oid => '8407', descr => 'for use by pg_upgrade',
+ proname => 'binary_upgrade_set_next_pg_subscription_oid', provolatile => 'v',
+ proparallel => 'r', prorettype => 'void', proargtypes => 'oid',
+ prosrc => 'binary_upgrade_set_next_pg_subscription_oid' },
# conversion functions
{ oid => '4302',
diff --git a/src/test/modules/spgist_name_ops/expected/spgist_name_ops.out b/src/test/modules/spgist_name_ops/expected/spgist_name_ops.out
index 1ee65ede243..39d43368c42 100644
--- a/src/test/modules/spgist_name_ops/expected/spgist_name_ops.out
+++ b/src/test/modules/spgist_name_ops/expected/spgist_name_ops.out
@@ -59,11 +59,12 @@ select * from t
binary_upgrade_set_next_multirange_pg_type_oid | 1 | binary_upgrade_set_next_multirange_pg_type_oid
binary_upgrade_set_next_pg_authid_oid | | binary_upgrade_set_next_pg_authid_oid
binary_upgrade_set_next_pg_enum_oid | | binary_upgrade_set_next_pg_enum_oid
+ binary_upgrade_set_next_pg_subscription_oid | | binary_upgrade_set_next_pg_subscription_oid
binary_upgrade_set_next_pg_tablespace_oid | | binary_upgrade_set_next_pg_tablespace_oid
binary_upgrade_set_next_pg_type_oid | | binary_upgrade_set_next_pg_type_oid
binary_upgrade_set_next_toast_pg_class_oid | 1 | binary_upgrade_set_next_toast_pg_class_oid
binary_upgrade_set_next_toast_relfilenode | | binary_upgrade_set_next_toast_relfilenode
-(13 rows)
+(14 rows)
-- Verify clean failure when INCLUDE'd columns result in overlength tuple
-- The error message details are platform-dependent, so show only SQLSTATE
@@ -108,11 +109,12 @@ select * from t
binary_upgrade_set_next_multirange_pg_type_oid | 1 | binary_upgrade_set_next_multirange_pg_type_oid
binary_upgrade_set_next_pg_authid_oid | | binary_upgrade_set_next_pg_authid_oid
binary_upgrade_set_next_pg_enum_oid | | binary_upgrade_set_next_pg_enum_oid
+ binary_upgrade_set_next_pg_subscription_oid | | binary_upgrade_set_next_pg_subscription_oid
binary_upgrade_set_next_pg_tablespace_oid | | binary_upgrade_set_next_pg_tablespace_oid
binary_upgrade_set_next_pg_type_oid | | binary_upgrade_set_next_pg_type_oid
binary_upgrade_set_next_toast_pg_class_oid | 1 | binary_upgrade_set_next_toast_pg_class_oid
binary_upgrade_set_next_toast_relfilenode | | binary_upgrade_set_next_toast_relfilenode
-(13 rows)
+(14 rows)
\set VERBOSITY sqlstate
insert into t values(repeat('xyzzy', 12), 42, repeat('xyzzy', 4000));
--
2.43.0
[text/x-patch] v19-0006-Allow-combined-conflict_log_destination-settings.patch (58.0K, ../../CALDaNm2YOOdJ25X1sJ+DYz37K6Qi4g0ZNFHb_pQMF9UqancnEA@mail.gmail.com/7-v19-0006-Allow-combined-conflict_log_destination-settings.patch)
download | inline diff:
From bf7ec689bd2be18a831d09284113c82220086ed3 Mon Sep 17 00:00:00 2001
From: Vignesh C <vignesh21@gmail.com>
Date: Mon, 5 Jan 2026 17:34:30 +0530
Subject: [PATCH v19 6/6] Allow combined conflict_log_destination settings
Extend conflict_log_destination handling to support combined destination
specifications. Previously, only log, table, or all were accepted. This change
allows combinations of them like log, table and all, log, table etc
---
src/backend/catalog/pg_subscription.c | 2 +-
src/backend/commands/subscriptioncmds.c | 92 +++++++++++++++-------
src/backend/replication/logical/conflict.c | 4 +-
src/bin/pg_dump/pg_dump.c | 44 ++++++++---
src/bin/pg_dump/t/002_pg_dump.pl | 4 +-
src/include/catalog/pg_subscription.h | 4 +-
src/include/commands/subscriptioncmds.h | 5 +-
src/include/replication/conflict.h | 9 ---
src/test/regress/expected/subscription.out | 72 +++++++++--------
src/test/regress/sql/subscription.sql | 11 ++-
10 files changed, 157 insertions(+), 90 deletions(-)
diff --git a/src/backend/catalog/pg_subscription.c b/src/backend/catalog/pg_subscription.c
index 1a93824504c..a7028d05506 100644
--- a/src/backend/catalog/pg_subscription.c
+++ b/src/backend/catalog/pg_subscription.c
@@ -147,7 +147,7 @@ GetSubscription(Oid subid, bool missing_ok)
datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
tup,
Anum_pg_subscription_subconflictlogdest);
- sub->conflictlogdest = TextDatumGetCString(datum);
+ sub->conflictlogdest = textarray_to_stringlist(DatumGetArrayTypeP(datum));
/* Is the subscription owner a superuser? */
sub->ownersuperuser = superuser_arg(sub->owner);
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index 6c865dacfd7..8db919405b6 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -60,6 +60,7 @@
#include "utils/pg_lsn.h"
#include "utils/regproc.h"
#include "utils/syscache.h"
+#include "utils/varlena.h"
/*
* Options that can be specified by the user in CREATE/ALTER SUBSCRIPTION
@@ -85,9 +86,6 @@
#define SUBOPT_ORIGIN 0x00020000
#define SUBOPT_CONFLICT_LOG_DEST 0x00040000
-/* check if the 'val' has 'bits' set */
-#define IsSet(val, bits) (((val) & (bits)) == (bits))
-
/*
* This will be set by the pg_upgrade_support function --
* binary_upgrade_set_next_pg_subscription_oid().
@@ -422,15 +420,21 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
else if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DEST) &&
strcmp(defel->defname, "conflict_log_destination") == 0)
{
- char *val;
- ConflictLogDest dest;
+ char *val;
+ List *dest;
if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_LOG_DEST))
errorConflictingDefElem(defel, pstate);
val = defGetString(defel);
- dest = GetLogDestination(val);
- opts->logdest = dest;
+ if (!SplitIdentifierString(val, ',', &dest))
+ ereport(ERROR,
+ errcode(ERRCODE_SYNTAX_ERROR),
+ errmsg("invalid list syntax in parameter \"%s\"",
+ "conflict_log_destination"));
+
+ opts->logdest = GetLogDestination(dest, false);
+
opts->specified_opts |= SUBOPT_CONFLICT_LOG_DEST;
}
else
@@ -610,6 +614,30 @@ publicationListToArray(List *publist)
return PointerGetDatum(arr);
}
+/*
+ * Build a text[] array representing the conflict_log_destination flags.
+ */
+static Datum
+ConflictLogDestFlagsToArray(ConflictLogDest logdest)
+{
+ Datum datums[3];
+ int ndatums = 0;
+
+ if (IsSet(logdest, CONFLICT_LOG_DEST_ALL))
+ datums[ndatums++] = CStringGetTextDatum("all");
+ else
+ {
+ if (IsSet(logdest, CONFLICT_LOG_DEST_LOG))
+ datums[ndatums++] = CStringGetTextDatum("log");
+
+ if (IsSet(logdest, CONFLICT_LOG_DEST_TABLE))
+ datums[ndatums++] = CStringGetTextDatum("table");
+ }
+
+ return PointerGetDatum(
+ construct_array_builtin(datums, ndatums, TEXTOID));
+}
+
/*
* Create new subscription.
*/
@@ -794,14 +822,13 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
/* Always set the destination, default will be 'log'. */
values[Anum_pg_subscription_subconflictlogdest - 1] =
- CStringGetTextDatum(ConflictLogDestNames[opts.logdest]);
+ ConflictLogDestFlagsToArray(opts.logdest);
/*
* If logging to a table is required, physically create the logging
* relation and store its OID in the catalog.
*/
- if (opts.logdest == CONFLICT_LOG_DEST_TABLE ||
- opts.logdest == CONFLICT_LOG_DEST_ALL)
+ if (IsSet(opts.logdest, CONFLICT_LOG_DEST_TABLE))
{
Oid logrelid;
@@ -1425,7 +1452,7 @@ AlterSubscriptionConflictLogDestination(Subscription *sub,
ConflictLogDest logdest,
Oid *conflicttablerelid)
{
- ConflictLogDest old_dest = GetLogDestination(sub->conflictlogdest);
+ ConflictLogDest old_dest = GetLogDestination(sub->conflictlogdest, true);
bool want_table;
bool has_oldtable;
bool update_relid = false;
@@ -1817,7 +1844,7 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_LOG_DEST))
{
ConflictLogDest old_dest =
- GetLogDestination(sub->conflictlogdest);
+ GetLogDestination(sub->conflictlogdest, true);
if (opts.logdest != old_dest)
{
@@ -1825,7 +1852,7 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
Oid relid = InvalidOid;
values[Anum_pg_subscription_subconflictlogdest - 1] =
- CStringGetTextDatum(ConflictLogDestNames[opts.logdest]);
+ ConflictLogDestFlagsToArray(opts.logdest);
replaces[Anum_pg_subscription_subconflictlogdest - 1] = true;
update_relid = AlterSubscriptionConflictLogDestination(sub, opts.logdest, &relid);
@@ -3477,27 +3504,38 @@ create_conflict_log_table(Oid subid, char *subname)
/*
* GetLogDestination
*
- * Convert string to enum by comparing against standardized labels.
+ * Convert log destination List of strings to enums.
*/
ConflictLogDest
-GetLogDestination(const char *dest)
+GetLogDestination(List *destlist, bool strnodelist)
{
- /* Empty string or NULL defaults to LOG. */
- if (dest == NULL || dest[0] == '\0' || pg_strcasecmp(dest, "log") == 0)
+ ConflictLogDest logdest = 0;
+ ListCell *cell;
+
+ if (destlist == NULL)
return CONFLICT_LOG_DEST_LOG;
- if (pg_strcasecmp(dest,
- ConflictLogDestNames[CONFLICT_LOG_DEST_TABLE]) == 0)
- return CONFLICT_LOG_DEST_TABLE;
+ foreach(cell, destlist)
+ {
+ char *name;
- if (pg_strcasecmp(dest, ConflictLogDestNames[CONFLICT_LOG_DEST_ALL]) == 0)
- return CONFLICT_LOG_DEST_ALL;
+ name = (strnodelist) ? strVal(lfirst(cell)) : (char *) lfirst(cell);
- /* Unrecognized string. */
- ereport(ERROR,
- (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
- errmsg("unrecognized conflict_log_destination value: \"%s\"", dest),
- errhint("Valid values are \"log\", \"table\", and \"all\".")));
+ if (pg_strcasecmp(name, "log") == 0)
+ logdest |= CONFLICT_LOG_DEST_LOG;
+ else if (pg_strcasecmp(name, "table") == 0)
+ logdest |= CONFLICT_LOG_DEST_TABLE;
+ else if (pg_strcasecmp(name, "all") == 0)
+ logdest |= CONFLICT_LOG_DEST_ALL;
+ else
+ ereport(ERROR,
+ errcode(ERRCODE_SYNTAX_ERROR),
+ errmsg("unrecognized value for subscription parameter \"%s\": \"%s\"",
+ "conflict_log_destination", name),
+ errhint("Valid values are \"log\", \"table\", and \"all\"."));
+ }
+
+ return logdest;
}
/*
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 6fce652dbcb..b9365c19975 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -181,7 +181,7 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
pgstat_report_subscription_conflict(MySubscription->oid, type);
/* Decide what detail to show in server logs. */
- if ((dest & CONFLICT_LOG_DEST_LOG) != 0)
+ if (IsSet(dest, CONFLICT_LOG_DEST_LOG) || IsSet(dest, CONFLICT_LOG_DEST_ALL))
{
StringInfoData err_detail;
@@ -271,7 +271,7 @@ GetConflictLogTableInfo(ConflictLogDest *log_dest)
* Convert the text log destination to the internal enum. MySubscription
* already contains the data from pg_subscription.
*/
- *log_dest = GetLogDestination(MySubscription->conflictlogdest);
+ *log_dest = GetLogDestination(MySubscription->conflictlogdest, true);
conflictlogrelid = MySubscription->conflictlogrelid;
/* If destination is 'log' only, no table to open. */
diff --git a/src/bin/pg_dump/pg_dump.c b/src/bin/pg_dump/pg_dump.c
index 44cc9f507a4..fc85a4beee4 100644
--- a/src/bin/pg_dump/pg_dump.c
+++ b/src/bin/pg_dump/pg_dump.c
@@ -5569,10 +5569,10 @@ dumpSubscription(Archive *fout, const SubscriptionInfo *subinfo)
DumpOptions *dopt = fout->dopt;
PQExpBuffer delq;
PQExpBuffer query;
- PQExpBuffer publications;
+ PQExpBuffer namebuf;
char *qsubname;
- char **pubnames = NULL;
- int npubnames = 0;
+ char **names = NULL;
+ int nnames = 0;
int i;
/* Do nothing if not dumping schema */
@@ -5600,19 +5600,22 @@ dumpSubscription(Archive *fout, const SubscriptionInfo *subinfo)
appendStringLiteralAH(query, subinfo->subconninfo, fout);
/* Build list of quoted publications and append them to query. */
- if (!parsePGArray(subinfo->subpublications, &pubnames, &npubnames))
+ if (!parsePGArray(subinfo->subpublications, &names, &nnames))
pg_fatal("could not parse %s array", "subpublications");
- publications = createPQExpBuffer();
- for (i = 0; i < npubnames; i++)
+ namebuf = createPQExpBuffer();
+ for (i = 0; i < nnames; i++)
{
if (i > 0)
- appendPQExpBufferStr(publications, ", ");
+ appendPQExpBufferStr(namebuf, ", ");
- appendPQExpBufferStr(publications, fmtId(pubnames[i]));
+ appendPQExpBufferStr(namebuf, fmtId(names[i]));
}
- appendPQExpBuffer(query, " PUBLICATION %s WITH (connect = false, slot_name = ", publications->data);
+ appendPQExpBuffer(query, " PUBLICATION %s WITH (connect = false, slot_name = ", namebuf->data);
+ resetPQExpBuffer(namebuf);
+ free(names);
+
if (subinfo->subslotname)
appendStringLiteralAH(query, subinfo->subslotname, fout);
else
@@ -5657,10 +5660,25 @@ dumpSubscription(Archive *fout, const SubscriptionInfo *subinfo)
appendPQExpBufferStr(query, ");\n");
+ /*
+ * Build list of quoted conflict log destinations and append them to
+ * query.
+ */
+ if (!parsePGArray(subinfo->subconflictlogdest, &names, &nnames))
+ pg_fatal("could not parse %s array", "conflict_log_destination");
+
+ for (i = 0; i < nnames; i++)
+ {
+ if (i > 0)
+ appendPQExpBufferStr(namebuf, ", ");
+
+ appendPQExpBuffer(namebuf, "%s", names[i]);
+ }
+
appendPQExpBuffer(query,
- "\n\nALTER SUBSCRIPTION %s SET (conflict_log_destination = %s);\n",
+ "\n\nALTER SUBSCRIPTION %s SET (conflict_log_destination = '%s');\n",
qsubname,
- subinfo->subconflictlogdest);
+ namebuf->data);
/*
* In binary-upgrade mode, we allow the replication to continue after the
@@ -5718,8 +5736,8 @@ dumpSubscription(Archive *fout, const SubscriptionInfo *subinfo)
NULL, subinfo->rolname,
subinfo->dobj.catId, 0, subinfo->dobj.dumpId);
- destroyPQExpBuffer(publications);
- free(pubnames);
+ destroyPQExpBuffer(namebuf);
+ free(names);
destroyPQExpBuffer(delq);
destroyPQExpBuffer(query);
diff --git a/src/bin/pg_dump/t/002_pg_dump.pl b/src/bin/pg_dump/t/002_pg_dump.pl
index 0df84cd5897..e06ac55db47 100644
--- a/src/bin/pg_dump/t/002_pg_dump.pl
+++ b/src/bin/pg_dump/t/002_pg_dump.pl
@@ -3204,10 +3204,10 @@ my %tests = (
create_order => 50,
create_sql => 'CREATE SUBSCRIPTION sub3
CONNECTION \'dbname=doesnotexist\' PUBLICATION pub1
- WITH (connect = false, origin = any, streaming = on, conflict_log_destination= table);',
+ WITH (connect = false, origin = any, streaming = on, conflict_log_destination= \'log,table\');',
regexp => qr/^
\QCREATE SUBSCRIPTION sub3 CONNECTION 'dbname=doesnotexist' PUBLICATION pub1 WITH (connect = false, slot_name = 'sub3', streaming = on);\E\n\n\n
- \QALTER SUBSCRIPTION sub3 SET (conflict_log_destination = table);\E
+ \QALTER SUBSCRIPTION sub3 SET (conflict_log_destination = 'all');\E
/xm,
like => { %full_runs, section_post_data => 1, },
unlike => {
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index 3d220b2db8a..92a742437c2 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -110,7 +110,7 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
* 'table' - internal table only,
* 'all' - both log and table.
*/
- text subconflictlogdest;
+ text subconflictlogdest[1] BKI_FORCE_NULL;
/* Only publish data originating from the specified origin */
text suborigin BKI_DEFAULT(LOGICALREP_ORIGIN_ANY);
@@ -169,7 +169,7 @@ typedef struct Subscription
List *publications; /* List of publication names to subscribe to */
char *origin; /* Only publish data originating from the
* specified origin */
- char *conflictlogdest; /* Conflict log destination */
+ List *conflictlogdest; /* Conflict log destination */
} Subscription;
#ifdef EXPOSE_TO_CLIENT_CODE
diff --git a/src/include/commands/subscriptioncmds.h b/src/include/commands/subscriptioncmds.h
index bc4a92af356..b977deef04e 100644
--- a/src/include/commands/subscriptioncmds.h
+++ b/src/include/commands/subscriptioncmds.h
@@ -19,6 +19,9 @@
#include "parser/parse_node.h"
#include "replication/conflict.h"
+/* check if the 'val' has 'bits' set */
+#define IsSet(val, bits) (((val) & (bits)) == (bits))
+
extern ObjectAddress CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bool isTopLevel);
extern ObjectAddress AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt, bool isTopLevel);
@@ -37,7 +40,7 @@ extern void CheckSubDeadTupleRetention(bool check_guc, bool sub_disabled,
bool retention_active,
bool max_retention_set);
-extern ConflictLogDest GetLogDestination(const char *dest);
+extern ConflictLogDest GetLogDestination(List *destlist, bool strnodelist);
extern bool IsConflictLogTable(Oid relid);
#endif /* SUBSCRIPTIONCMDS_H */
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index b60c0b03e26..3b5092d6584 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -100,15 +100,6 @@ typedef enum ConflictLogDest
CONFLICT_LOG_DEST_ALL = (CONFLICT_LOG_DEST_LOG | CONFLICT_LOG_DEST_TABLE)
} ConflictLogDest;
-/*
- * Array mapping for converting internal enum to string.
- */
-static const char *const ConflictLogDestNames[] = {
- [CONFLICT_LOG_DEST_LOG] = "log",
- [CONFLICT_LOG_DEST_TABLE] = "table",
- [CONFLICT_LOG_DEST_ALL] = "all"
-};
-
/* Structure to hold metadata for one column of the conflict log table */
typedef struct ConflictLogColumnDef
{
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index 84abbaa5a4a..8d9d7dfc131 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -119,7 +119,7 @@ HINT: To initiate replication, you must manually create the replication slot, e
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub4 SET (origin = any);
@@ -127,7 +127,7 @@ ALTER SUBSCRIPTION regress_testsub4 SET (origin = any);
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
DROP SUBSCRIPTION regress_testsub3;
@@ -148,7 +148,7 @@ ERROR: invalid connection string syntax: missing "=" after "foobar" in connecti
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET PUBLICATION testpub2, testpub3 WITH (refresh = false);
@@ -160,7 +160,7 @@ ALTER SUBSCRIPTION regress_testsub SET (run_as_owner = true);
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (password_required = true);
@@ -179,7 +179,7 @@ ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/12345');
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345 | log
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345 | {log}
(1 row)
-- ok - with lsn = NONE
@@ -191,7 +191,7 @@ ERROR: invalid WAL location (LSN): 0/0
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | {log}
(1 row)
BEGIN;
@@ -226,7 +226,7 @@ HINT: Available values: local, remote_write, remote_apply, on, off.
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
---------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
- regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000 | log
+ regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000 | {log}
(1 row)
-- rename back to keep the rest simple
@@ -258,7 +258,7 @@ HINT: To initiate replication, you must manually create the replication slot, e
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (binary = false);
@@ -267,7 +267,7 @@ ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -282,7 +282,7 @@ HINT: To initiate replication, you must manually create the replication slot, e
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = parallel);
@@ -290,7 +290,7 @@ ALTER SUBSCRIPTION regress_testsub SET (streaming = parallel);
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = false);
@@ -299,7 +299,7 @@ ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
-- fail - publication already exists
@@ -317,7 +317,7 @@ ERROR: publication "testpub1" is already in subscription "regress_testsub"
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
-- fail - publication used more than once
@@ -335,7 +335,7 @@ ALTER SUBSCRIPTION regress_testsub DROP PUBLICATION testpub1, testpub2 WITH (ref
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -374,7 +374,7 @@ HINT: To initiate replication, you must manually create the replication slot, e
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
-- we can alter streaming when two_phase enabled
@@ -383,7 +383,7 @@ ALTER SUBSCRIPTION regress_testsub SET (streaming = true);
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -396,7 +396,7 @@ HINT: To initiate replication, you must manually create the replication slot, e
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -412,7 +412,7 @@ HINT: To initiate replication, you must manually create the replication slot, e
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (disable_on_error = true);
@@ -420,7 +420,7 @@ ALTER SUBSCRIPTION regress_testsub SET (disable_on_error = true);
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -436,7 +436,7 @@ HINT: To initiate replication, you must manually create the replication slot, e
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -453,7 +453,7 @@ HINT: To initiate replication, you must manually create the replication slot, e
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
-- ok
@@ -462,7 +462,7 @@ ALTER SUBSCRIPTION regress_testsub SET (max_retention_duration = 0);
List of subscriptions
Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | {log}
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -523,7 +523,7 @@ DROP SUBSCRIPTION regress_testsub;
SET SESSION AUTHORIZATION 'regress_subscription_user';
-- fail - unrecognized parameter value
CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
-ERROR: unrecognized conflict_log_destination value: "invalid"
+ERROR: unrecognized value for subscription parameter "conflict_log_destination": "invalid"
HINT: Valid values are "log", "table", and "all".
-- verify subconflictlogdest is 'log' and relid is 0 (InvalidOid) for default case
CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
@@ -533,7 +533,7 @@ SELECT subname, subconflictlogdest, subconflictlogrelid
FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
subname | subconflictlogdest | subconflictlogrelid
------------------------------+--------------------+---------------------
- regress_conflict_log_default | log | 0
+ regress_conflict_log_default | {log} | 0
(1 row)
-- verify empty string defaults to 'log'
@@ -544,11 +544,11 @@ SELECT subname, subconflictlogdest, subconflictlogrelid
FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
subname | subconflictlogdest | subconflictlogrelid
----------------------------+--------------------+---------------------
- regress_conflict_empty_str | log | 0
+ regress_conflict_empty_str | {log} | 0
(1 row)
-- this should generate an internal table named pg_conflict_$subid$
-CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log, table');
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
-- check metadata in pg_subscription: destination should be 'table' and relid valid
@@ -556,7 +556,7 @@ SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
FROM pg_subscription WHERE subname = 'regress_conflict_test1';
subname | subconflictlogdest | has_relid
------------------------+--------------------+-----------
- regress_conflict_test1 | table | t
+ regress_conflict_test1 | {all} | t
(1 row)
-- verify the physical table exists and its OID matches subconflictlogrelid
@@ -586,18 +586,28 @@ WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0;
-- These tests verify the transition logic between different logging
-- destinations, ensuring internal tables are created or dropped as expected.
--
--- transition from 'log' to 'all'
+-- transition from 'log' to 'log, table'
-- a new internal conflict log table should be created
CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log, table');
+-- verify metadata after ALTER (destination should be 'log, table')
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogdest | has_relid
+------------------------+--------------------+-----------
+ regress_conflict_test2 | {all} | t
+(1 row)
+
+-- transition from 'log, table' to 'all'
ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
-- verify metadata after ALTER (destination should be 'all')
SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
FROM pg_subscription WHERE subname = 'regress_conflict_test2';
subname | subconflictlogdest | has_relid
------------------------+--------------------+-----------
- regress_conflict_test2 | all | t
+ regress_conflict_test2 | {all} | t
(1 row)
-- transition from 'all' to 'table'
@@ -608,7 +618,7 @@ SELECT subconflictlogdest, subconflictlogrelid = :old_relid AS relid_unchanged
FROM pg_subscription WHERE subname = 'regress_conflict_test2';
subconflictlogdest | relid_unchanged
--------------------+-----------------
- table | t
+ {table} | t
(1 row)
-- transition from 'table' to 'log'
@@ -618,7 +628,7 @@ SELECT subconflictlogdest, subconflictlogrelid
FROM pg_subscription WHERE subname = 'regress_conflict_test2';
subconflictlogdest | subconflictlogrelid
--------------------+---------------------
- log | 0
+ {log} | 0
(1 row)
-- verify the physical table is gone
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index 83befa8722c..4ee96d4fcf2 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -385,7 +385,7 @@ SELECT subname, subconflictlogdest, subconflictlogrelid
FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
-- this should generate an internal table named pg_conflict_$subid$
-CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log, table');
-- check metadata in pg_subscription: destination should be 'table' and relid valid
SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
@@ -411,9 +411,16 @@ WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0;
-- destinations, ensuring internal tables are created or dropped as expected.
--
--- transition from 'log' to 'all'
+-- transition from 'log' to 'log, table'
-- a new internal conflict log table should be created
CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log, table');
+
+-- verify metadata after ALTER (destination should be 'log, table')
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'log, table' to 'all'
ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
-- verify metadata after ALTER (destination should be 'all')
--
2.43.0
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-05 15:02 vignesh C <vignesh21@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: vignesh C @ 2026-01-05 15:02 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Dilip Kumar <dilipbalaut@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Mon, 5 Jan 2026 at 09:59, shveta malik <shveta.malik@gmail.com> wrote:
>
> On Fri, Jan 2, 2026 at 12:06 PM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > On Fri, Dec 26, 2025 at 8:58 PM vignesh C <vignesh21@gmail.com> wrote:
> > >
> > >
> > > Here is a rebased version of the remaining patches.
> > >
> >
> > Thank You Vignesh. Please find a few comments on 004:
> >
>
> Vignesh, please find a few comments on 005.
>
> 1)
> AlterSubscriptionConflictLogDestination()
>
> + if (want_table && !has_oldtable)
> + {
> + char relname[NAMEDATALEN];
> +
> + snprintf(relname, NAMEDATALEN, "conflict_log_table_%u", sub->oid);
> +
> + relid = get_relname_relid(relname, PG_CONFLICT_NAMESPACE);
> + if (OidIsValid(relid))
>
>
> We have added this new scenario wherein we check if CLT is present
> already and if so, just set it in subid.
>
> a) Where will this scenario be hit? Can we please add the comments?
> On trying pg_dump, I see that it does not dump CLT and thus above will
> not be hit in pg_dump at least.
This is done for upgrade scenarios to associate with the pre-existing
conflict log table. Included comments.
> b) Even if we have a valid scenario where we have a pre-existing CLT
> and sub is created later, how/where are we ensuring that subid in CLT
> name will match newly generated subid?
It was generating different subid, updated it to preserve the old
subscription id.
> 2)
> + if (IsConflictLogTable(relid))
> + ereport(ERROR,
> + errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
> + errmsg("conflict log table \"%s.%s\" cannot be used",
> + nspname, relname),
> + errdetail("The specified table is already registered for a different
> subscription."),
> + errhint("Specify a different conflict log table."));
>
> a) Since the user is not specifying the CLT name, errhint seems incorrect.
>
> b) Again, I am unable to understand when this error will be hit? Since
> CLT is internally created using subid of owning subscription, how CLT
> of a particular subid be connected to subscription of different subid
> to result in above error? Can you please add comments to explain the
> situation.
Now these error scenarios are not possible. Removed these.
The v19 version patch attached at [1] has the changes for the same.
[1] - https://www.postgresql.org/message-id/CALDaNm2YOOdJ25X1sJ%2BDYz37K6Qi4g0ZNFHb_pQMF9UqancnEA%40mail.g...
Regards,
Vignesh
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-06 03:21 shveta malik <shveta.malik@gmail.com>
parent: vignesh C <vignesh21@gmail.com>
0 siblings, 0 replies; 912+ messages in thread
From: shveta malik @ 2026-01-06 03:21 UTC (permalink / raw)
To: vignesh C <vignesh21@gmail.com>; +Cc: Dilip Kumar <dilipbalaut@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
I was checking patch006. I understand the purpose of patch 006, but I
don’t think it’s needed at the moment. Currently, we support only two
destinations: log and table, and we already provide 'all' to cover
both. This patch would make sense if we either supported at least
three destinations or didn’t have the 'all' option. As it stands, none
of the comma-separated combinations are meaningful:
log,all
table,all
log,table (already covered by all)
Should we defer this patch until we actually support additional
destinations? One option is to remove 'all', but for now, 'all' feels
more appropriate than introducing comma-separated values.
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-06 23:43 Peter Smith <smithpb2250@gmail.com>
parent: vignesh C <vignesh21@gmail.com>
2 siblings, 0 replies; 912+ messages in thread
From: Peter Smith @ 2026-01-06 23:43 UTC (permalink / raw)
To: vignesh C <vignesh21@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Dilip Kumar <dilipbalaut@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Hi Dilip.
Some review comments for patch v19-0001.
======
GENERAL
0. Apply warnings due to whitespace
$ git apply ../patches_misc/v19-0001-Add-configurable-conflict-log-table-for-Logical-.patch
../patches_misc/v19-0001-Add-configurable-conflict-log-table-for-Logical-.patch:821:
trailing whitespace.
* Internally, we use these for bitwise comparisons (IsSet), but the string
warning: 1 line adds whitespace errors.
======
src/backend/catalog/namespace.c
CheckSetNamespace:
1.
Do you also need to update the function comment? Currently, it
mentions all the other schemas but not the PG_CONFLICT schema.
======
src/backend/commands/subscriptioncmds.c
parse_subscription_options:
2.
+ char *val;
+ ConflictLogDest dest;
+
+ if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_LOG_DEST))
+ errorConflictingDefElem(defel, pstate);
+
+ val = defGetString(defel);
+ dest = GetLogDestination(val);
+ opts->logdest = dest;
Is there any purpose for the variable 'dest'?
SUGGESTION
opts->logdest = GetLogDestination(val);
~~~
CreateSubscription:
3.
+ /*
+ * If logging to a table is required, physically create the logging
+ * relation and store its OID in the catalog.
+ */
+ if (opts.logdest == CONFLICT_LOG_DEST_TABLE ||
+ opts.logdest == CONFLICT_LOG_DEST_ALL)
Those enum values are bits. This condition ought to use bitwise logic.
SUGGESTION
if (IsSet(opts.logdest, CONFLICT_LOG_DEST_TABLE))
~~~
4.
+ /* Destination is 'log'; no table is needed. */
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(InvalidOid);
This comment does not need to say "Destination is 'log'". And, one day
in the future, there might be more types, and then this comment would
be wrong.
SUGGESTION
/* No conflict log table is needed. */
~~~
5.
+ /*
+ * If logging to a table is required, physically create the logging
+ * relation and store its OID in the catalog.
+ */
+ if (opts.logdest == CONFLICT_LOG_DEST_TABLE ||
+ opts.logdest == CONFLICT_LOG_DEST_ALL)
+ {
+ Oid logrelid;
+
+ /* Store the Oid returned from creation. */
+ logrelid = create_conflict_log_table(subid, stmt->subname);
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(logrelid);
+ }
+ else
+ {
+ /* Destination is 'log'; no table is needed. */
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(InvalidOid);
+ }
In fact, isn't it simpler to remove that 'else' entirely, leaving just
one values[] assignment?
SUGGESTION:
Oid logrelid = InvalidOid;
if (...)
{
logrelid = create_conflict_log_table(...);
}
values[...] = ObjectIdGetDatum(logrelid);
~~~
create_conflict_log_table:
6.
+/*
+ * Create a structured conflict log table for a subscription.
+ *
+ * The table is created within the dedicated 'pg_conflict' namespace, which
+ * is system-managed. The table name is generated automatically using the
+ * subscription's OID (e.g., "pg_conflict_<subid>") to ensure uniqueness
+ * within the cluster and to avoid collisions during subscription renames.
+ */
+static Oid
+create_conflict_log_table(Oid subid, char *subname)
IMO, the sentence "The table is created..." is ambiguous. e.g. What is
it trying to say is "system-managed" -- the table, or the namespace?
~~~
7.
+ relid = heap_create_with_catalog(relname,
+ PG_CONFLICT_NAMESPACE,
+ 0,
+ InvalidOid,
+ InvalidOid,
+ InvalidOid,
+ GetUserId(),
+ HEAP_TABLE_AM_OID,
+ tupdesc,
+ NIL,
+ RELKIND_RELATION,
+ RELPERSISTENCE_PERMANENT,
+ false,
+ false,
+ ONCOMMIT_NOOP,
+ (Datum) 0,
+ false,
+ true,
+ false,
+ InvalidOid,
+ NULL);
I saw this is similar to other heap_create_with_catalog() calls in the
PG source, but all the same, IMO, it's impossible to understand the
intent of all those true/false/InvalidOid params. Can you give a brief
comment per param line to say what they are for?
SUGGESTION
+ relid = heap_create_with_catalog(relname,
+ PG_CONFLICT_NAMESPACE,
+ 0, /* tablespace */
+ InvalidOid, /* relid */
+ InvalidOid, /* reltypeid */
+ InvalidOid, /* reloftypeid */
+ GetUserId(),
+ HEAP_TABLE_AM_OID,
+ tupdesc,
+ NIL,
+ RELKIND_RELATION,
+ RELPERSISTENCE_PERMANENT,
+ false, /* shared_relation */
+ false, /* mapped_relation */
+ ONCOMMIT_NOOP,
+ (Datum) 0, /* reloptions */
+ false, /* use_user_acl */
+ true, /* allow_system_table_mods */
+ false, /* is_internal */
+ InvalidOid, /* relrewrite */
+ NULL); /* typaddress */
e.g. Now that we can see better what those parameters are for, I
wonder why 'is_internal' is being passed as false?
~~~
GetLogDestination:
8.
+/*
+ * GetLogDestination
+ *
+ * Convert string to enum by comparing against standardized labels.
+ */
+ConflictLogDest
+GetLogDestination(const char *dest)
+{
+ /* Empty string or NULL defaults to LOG. */
+ if (dest == NULL || dest[0] == '\0' || pg_strcasecmp(dest, "log") == 0)
+ return CONFLICT_LOG_DEST_LOG;
+
+ if (pg_strcasecmp(dest,
+ ConflictLogDestNames[CONFLICT_LOG_DEST_TABLE]) == 0)
+ return CONFLICT_LOG_DEST_TABLE;
+
+ if (pg_strcasecmp(dest, ConflictLogDestNames[CONFLICT_LOG_DEST_ALL]) == 0)
+ return CONFLICT_LOG_DEST_ALL;
+
+ /* Unrecognized string. */
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("unrecognized conflict_log_destination value: \"%s\"", dest),
+ errhint("Valid values are \"log\", \"table\", and \"all\".")));
+}
I felt this function has a weird mixture of name lookups from
ConflictLogDestNames[] alongside hardwired strings for "log", "table",
and "all".
If you are going to hardwire any of them, you might as well hardwire
all of them -- IMO it's more readable too.
And, if there is any concern about those hardwired strings being
correct, then just introduce some asserts to ensure everything is
sane.
SUGGESTION
Assert(pg_strcasecmp("log", ConflictLogDestNames[CONFLICT_LOG_DEST_LOG]) == 0);
Assert(pg_strcasecmp("table",
ConflictLogDestNames[CONFLICT_LOG_DEST_TABLE]) == 0);
Assert(pg_strcasecmp("all", ConflictLogDestNames[CONFLICT_LOG_DEST_ALL]) == 0);
...
if (pg_strcasecmp(dest, "table") == 0)
return CONFLICT_LOG_DEST_TABLE;
if (pg_strcasecmp(dest, "all") == 0)
return CONFLICT_LOG_DEST_ALL;
~~~
IsConflictLogTable:
9.
+/*
+ * Check if the specified relation is used as a conflict log table by any
+ * subscription.
+ */
+bool
+IsConflictLogTable(Oid relid)
+{
+ Relation rel;
+ TableScanDesc scan;
+ HeapTuple tup;
+ bool is_clt = false;
+
+ rel = table_open(SubscriptionRelationId, AccessShareLock);
+ scan = table_beginscan_catalog(rel, 0, NULL);
+
+ while (HeapTupleIsValid(tup = heap_getnext(scan, ForwardScanDirection)))
This seemed like excessive logic to me -- why do we need to scan
through the subscriptions?
Doesn't the very fact that the table lives in the 'pg_conflict'
namespace mean that it *must* (by definition) be a conflict log table?
Therefore, isn't the namespace of this relid the only thing you need
to discover to know if it is a CLT or not?
======
src/include/catalog/pg_namespace.dat
10.
+{ oid => '1382', oid_symbol => 'PG_CONFLICT_NAMESPACE',
+ descr => 'reserved schema for subscription-specific conflict tables',
+ nspname => 'pg_conflict', nspacl => '_null_' },
Maybe better to use the same terminology as everywhere else:
/conflict tables/conflict log tables/
======
src/include/replication/conflict.h
11.
+typedef enum ConflictLogDest
+{
+ /* Log conflicts to the server logs */
+ CONFLICT_LOG_DEST_LOG = 1 << 0, /* 0x01 */
+
+ /* Log conflicts to an internally managed table */
+ CONFLICT_LOG_DEST_TABLE = 1 << 1, /* 0x02 */
+
+ /* Convenience flag for all supported destinations */
+ CONFLICT_LOG_DEST_ALL = (CONFLICT_LOG_DEST_LOG | CONFLICT_LOG_DEST_TABLE)
+} ConflictLogDest;
To emphasise that these enums are really bitmasks, I felt it would be
helpful to name them like:
CONFLICT_LOG_DEST_BIT_LOG
CONFLICT_LOG_DEST_BIT_TABLE
CONFLICT_LOG_DEST_BM_ALL
~~~
12.
+/* Define the count using the array size */
+#define MAX_CONFLICT_ATTR_NUM (sizeof(ConflictLogSchema) /
sizeof(ConflictLogSchema[0]))
Is that comment useful? The code is saying the same thing quite clearly.
Anyway, would it be better to write it as:
#define MAX_CONFLICT_ATTR_NUM lengthof(ConflictLogSchema)
======
src/test/regress/sql/subscription.sql
13.
+-- verify the physical table exists and its OID matches subconflictlogrelid
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND c.oid = s.subconflictlogrelid;
Somewhere nearby this test, should you also verify that the CLT was
created within the 'pg_conflict' namespace?
~~~
14.
+-- ensure drop table not allowed and DROP SUBSCRIPTION reaps the table
IIUC, this was the comment that introduces the next group of "reaping"
tests, so this should also be a bit group comment like the one for the
state transitions.
~~~
15.
+-- PUBLICATION: Verify internal tables are not publishable
+-- pg_relation_is_publishable should return false for internal
conflict log tables
+SELECT pg_relation_is_publishable(subconflictlogrelid)
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
This test case seemed buried among some of the other "reaping" tests.
It should be moved outside of this group.
~~~
16.
+DROP SCHEMA clt;
There is no such schema. This 'clt' looks like a hangover from old
patch tests, so it should be removed.
======
Kind Regards,
Peter Smith.
Fujitsu Australia
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-07 02:44 Peter Smith <smithpb2250@gmail.com>
parent: vignesh C <vignesh21@gmail.com>
2 siblings, 1 reply; 912+ messages in thread
From: Peter Smith @ 2026-01-07 02:44 UTC (permalink / raw)
To: vignesh C <vignesh21@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Dilip Kumar <dilipbalaut@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Hi Dilip
Some review comments for the documentation patch v19-0003.
======
GENERAL
0.
FYI - The applied patch fails to build for me.
logical-replication.sgml:702: parser error : Opening and ending tag
mismatch: para line 292 and sect1
</sect1>
^
logical-replication.sgml:3666: parser error : Opening and ending tag
mismatch: sect1 line 203 and chapter
</chapter>
~~~
AFAICT, it was caused by a missing </para> for the first paragraph of the patch.
======
doc/src/sgml/logical-replication.sgml
(29.2. Subscription)
1.
+ Conflicts that occur during replication are, by default, logged as
plain text
+ in the server log, which can make automated monitoring and
analysis difficult.
+ The <command>CREATE SUBSCRIPTION</command> command provides the
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination">
+ <literal>conflict_log_destination</literal></link> option to record detailed
+ conflict information in a structured, queryable format. When this parameter
+ is set to <literal>table</literal> or <literal>all</literal>, the system
+ automatically manages a dedicated conflict storage table, which is created
+ and dropped along with the subscription. This significantly
improves post-mortem
+ analysis and operational visibility of the replication setup.
"a dedicated conflict storage table"
Let's refer to this using consistent terminology like "a dedicated
conflict log table"
~~~
(29.8. Conflicts)
2.
+ in the following <firstterm>conflict</firstterm> cases. If the subscription
+ was created with the <literal>conflict_log_destination</literal> set to
+ <literal>table</literal> or <literal>all</literal>, detailed conflict
+ information is inserted into an internally managed table named
+ <literal>pg_conflict.pg_conflict_<replaceable>subscription_oid</replaceable>
+ </literal>, providing a structured record of all conflicts.
2a.
That "conflict_log_destination" should include a link back to the
CREATE SUBSCRIPTION page.
~
2b.
BUT... This information should all be removed from here to where the
next patch change is (see the following review comment). Otherwise, it
is just repeating more of the same information. [see the next review
comment]
~~~
3.
<para>
- The log format for logical replication conflicts is as follows:
+ When the <literal>conflict_log_destination</literal> is set to
+ <literal>table</literal> or <literal>all</literal>, the system automatically
+ creates a new table with a predefined schema to log conflict details. This
+ table is created in the dedicated <literal>pg_conflict</literal> namespace.
+ The schema of this table is detailed in
+ <xref linkend="logical-replication-conflict-log-schema"/>.
+ </para>
IMO, that earlier information (see review comment #2) about the table
name can just be included here. Something like this:
SUGGESTION (markup/links are missing in my example)
When the conflict_log_destination is set to table or all, the system
automatically creates a new table to log conflict details. This table
is created in the dedicated pg_conflict namespace. The name of the
conflict log table is
pg_conflict_<replaceable>subscription_oid</replaceable>. The schema of
this table is detailed in Table 29.3.
~~~
4.
There was some other unchanged sentence:
"Note that there are other conflict scenarios, such as exclusion
constraint violations. Currently, we do not provide additional details
for them in the log."
I think the wording of the 2nd sentence does not account for the new
CLT, as "in the log" sounds like referring to the system log file to
me.
It can be changed to be generic so that it can also apply to the CLT. e.g.:
"Currently, we do not log additional details for them."
~~~
5.
+ <para>
+ The conflicting row data, including the original local tuple and
+ the remote tuple, is stored in <type>JSON</type> columns
(<literal>local_tuple</literal>
+ and <literal>remote_tuple</literal>) for flexible querying and analysis.
+ </para>
But that table schema did not have anything called 'local_tuple' (??).
~~~
6.
IMO, all the details about the format of the CLT Schema and the Log
File were running into each other, making it harder to read. It would
be better to introduce a couple of subsections to keep those apart.
e.g.
Section 29.8.1 "Logging conflicts in a Conflict Log Table"
Section 29.8.2 "Logging conflicts in the System Log"
~~~
(29.9. Restrictions)
7.
+ <listitem>
+ <para>
+ The internal table automatically created when
<literal>conflict_log_destination</literal>
+ is set to <literal>table</literal> or <literal>all</literal> is
excluded from
+ logical replication. It will not be published, even if a
publication on the
+ subscriber is defined using <literal>FOR ALL TABLES</literal>.
+ </para>
+ </listitem>
That "conflict_log_destination" should include a link back to the
CREATE SUBSCRIPTION page.
======
doc/src/sgml/ref/alter_subscription.sgml
ALTER SET
8.
+ <para>
+ When the <literal>conflict_log_destination</literal> parameter is set to
+ <literal>table</literal> or <literal>all</literal>, the system
+ automatically creates the internal logging table if it does not already
+ exist. Conversely, if the destination is changed to
+ <literal>log</literal>, logging to the table stops and the internal
+ table is automatically dropped.
+ </para>
8a.
That 'conflict_log_destination' should include the link to get back to
the parameter definition on the CREATE SUBSCRIPTION page (e.g. same as
all the other parameters do in the preceding paragraph)
~
8b.
"internal logging table"
Maybe use the consistent terminology and call this the "internal
conflict log table"
======
doc/src/sgml/ref/create_subscription.sgml
conflict_log_destination:
9.
It wasn't clear to me if this was intended to be in alphabetical order
or not; in case it was, note that 'conflict' comes before 'copy'.
~~~
10.
+ The supported values are <literal>log</literal>,
<literal>table</literal>,
+ and <literal>all</literal>. The default is <literal>log</literal>.
These sentences are not necessary because they are just the same
information that is repeated in the itemized list.
~~~
11.
+ <literal>table</literal>: The system automatically
creates a structured table
+ named
<literal>pg_conflict.conflict_log_table_<subid></literal>.
I felt that the schema name isn't really part of the table name.
SUGGESTION
The system automatically creates a structured table named
<literal>conflict_log_table_<subid></literal> in the
<literal>pg_conflict</literal> schema.
~~~
12.
+ <para>
+ <literal>all</literal>: Records the conflict information
to both the server log
+ and the dedicated conflict table.
+ </para>
+ </listitem>
IMO it's better to just describe these in terms of those other enums,
instead of trying to explain again what they do.
SUGGESTION
all: Same as a combination of log and table.
~~~
13.
I felt that this section should also include links "See xxx for more
details" to the appropriate logic-replication.sgml sections for the
System log formats and the CLT conflict formats.
======
Kind Regards,
Peter Smith.
Fujitsu Australia
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-07 06:42 shveta malik <shveta.malik@gmail.com>
parent: Peter Smith <smithpb2250@gmail.com>
0 siblings, 2 replies; 912+ messages in thread
From: shveta malik @ 2026-01-07 06:42 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>; Peter Smith <smithpb2250@gmail.com>
Hi Dilip,
Please find a few comments on v19-001:
1)
We can replace IsConflictLogTable(relid) with
IsConflictClass(reltuple). Wherever we call IsConflictLogTable(), we
have already fetched reltuple from pg_class for that relid, we can
simply use that to see if it belongs to pg_conflict namespace. That
will avoid the need of
patch004 as well to 'Introduce a dedicated shared unique index on
pg_subscription.subconflictlogrelid'.
2)
+ if (opts.logdest == CONFLICT_LOG_DEST_TABLE ||
+ opts.logdest == CONFLICT_LOG_DEST_ALL)
We can replace with:
IsSet(opts.logdest, CONFLICT_LOG_DEST_TABLE);
3)
+-- this should generate an internal table named pg_conflict_$subid$
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION
'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect =
false, conflict_log_destination = 'table');
+
I think we shall verify 2 things here:
a) Table is created in pg_conflict namespace.
b) Table has name pg_conflict_<subid>
4)
We can add these 3 simple tests as well:
a) Trying to alter and truncate pg_conflict_subid table.
b) Trying to create a new table in pg_conflict namespace.
c) Moving a table into pg_conflict namespace.
~~
Overall, I’m concerned about how users will manage this table as it
grows. There is currently no way to purge old data, truncation is
disallowed, and the table must be sub-ID–tied, which also prevents
users from attaching a different table as a CLT (if needed).
Additionally, we do not offer any form of partitioning.
Do you think we should provide users with a basic purge mechanism? At
the very least, should we allow truncation so users can take a backup
and truncate a sub-ID–tied CLT to start afresh? Thoughts?
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-07 10:56 Dilip Kumar <dilipbalaut@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2026-01-07 10:56 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; Peter Smith <smithpb2250@gmail.com>
On Wed, Jan 7, 2026 at 12:12 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> Hi Dilip,
> Please find a few comments on v19-001:
>
> 1)
> We can replace IsConflictLogTable(relid) with
> IsConflictClass(reltuple). Wherever we call IsConflictLogTable(), we
> have already fetched reltuple from pg_class for that relid, we can
> simply use that to see if it belongs to pg_conflict namespace. That
> will avoid the need of
> patch004 as well to 'Introduce a dedicated shared unique index on
> pg_subscription.subconflictlogrelid'.
>
> 2)
> + if (opts.logdest == CONFLICT_LOG_DEST_TABLE ||
> + opts.logdest == CONFLICT_LOG_DEST_ALL)
>
> We can replace with:
>
> IsSet(opts.logdest, CONFLICT_LOG_DEST_TABLE);
>
> 3)
> +-- this should generate an internal table named pg_conflict_$subid$
> +CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION
> 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect =
> false, conflict_log_destination = 'table');
> +
>
> I think we shall verify 2 things here:
> a) Table is created in pg_conflict namespace.
> b) Table has name pg_conflict_<subid>
>
> 4)
> We can add these 3 simple tests as well:
> a) Trying to alter and truncate pg_conflict_subid table.
> b) Trying to create a new table in pg_conflict namespace.
> c) Moving a table into pg_conflict namespace.
>
> ~~
>
> Overall, I’m concerned about how users will manage this table as it
> grows. There is currently no way to purge old data, truncation is
> disallowed, and the table must be sub-ID–tied, which also prevents
> users from attaching a different table as a CLT (if needed).
> Additionally, we do not offer any form of partitioning.
> Do you think we should provide users with a basic purge mechanism? At
> the very least, should we allow truncation so users can take a backup
> and truncate a sub-ID–tied CLT to start afresh? Thoughts?
Yeah that's a valid concern, there should be some way to purge data
from this table, I think we can allow truncate/delete on this table,
currently I haven't blocked the DML operations on that table and
similarly we can allow truncate as well.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-07 12:01 Amit Kapila <amit.kapila16@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 0 replies; 912+ messages in thread
From: Amit Kapila @ 2026-01-07 12:01 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; vignesh C <vignesh21@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; Peter Smith <smithpb2250@gmail.com>
On Wed, Jan 7, 2026 at 4:26 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Wed, Jan 7, 2026 at 12:12 PM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > Hi Dilip,
> > Please find a few comments on v19-001:
> >
> > 1)
> > We can replace IsConflictLogTable(relid) with
> > IsConflictClass(reltuple). Wherever we call IsConflictLogTable(), we
> > have already fetched reltuple from pg_class for that relid, we can
> > simply use that to see if it belongs to pg_conflict namespace. That
> > will avoid the need of
> > patch004 as well to 'Introduce a dedicated shared unique index on
> > pg_subscription.subconflictlogrelid'.
> >
> > 2)
> > + if (opts.logdest == CONFLICT_LOG_DEST_TABLE ||
> > + opts.logdest == CONFLICT_LOG_DEST_ALL)
> >
> > We can replace with:
> >
> > IsSet(opts.logdest, CONFLICT_LOG_DEST_TABLE);
> >
> > 3)
> > +-- this should generate an internal table named pg_conflict_$subid$
> > +CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION
> > 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect =
> > false, conflict_log_destination = 'table');
> > +
> >
> > I think we shall verify 2 things here:
> > a) Table is created in pg_conflict namespace.
> > b) Table has name pg_conflict_<subid>
> >
> > 4)
> > We can add these 3 simple tests as well:
> > a) Trying to alter and truncate pg_conflict_subid table.
> > b) Trying to create a new table in pg_conflict namespace.
> > c) Moving a table into pg_conflict namespace.
> >
> > ~~
> >
> > Overall, I’m concerned about how users will manage this table as it
> > grows. There is currently no way to purge old data, truncation is
> > disallowed, and the table must be sub-ID–tied, which also prevents
> > users from attaching a different table as a CLT (if needed).
> > Additionally, we do not offer any form of partitioning.
> > Do you think we should provide users with a basic purge mechanism? At
> > the very least, should we allow truncation so users can take a backup
> > and truncate a sub-ID–tied CLT to start afresh? Thoughts?
>
> Yeah that's a valid concern, there should be some way to purge data
> from this table, I think we can allow truncate/delete on this table,
>
+1 for allowing truncate and delete to subscription owners.
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-08 06:59 shveta malik <shveta.malik@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
1 sibling, 2 replies; 912+ messages in thread
From: shveta malik @ 2026-01-08 06:59 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; Peter Smith <smithpb2250@gmail.com>; shveta malik <shveta.malik@gmail.com>
On Wed, Jan 7, 2026 at 12:12 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> Hi Dilip,
> Please find a few comments on v19-001:
>
Please find a few comments for v19-002's part I have reviewed so far:
1)
ReportApplyConflict:
+ if ((dest & CONFLICT_LOG_DEST_TABLE) != 0)
+ if ((dest & CONFLICT_LOG_DEST_LOG) != 0)
We can use IsSet everywhere
2)
GetConflictLogTableInfo
This function gets dest and opens table, shall we rename to :
GetConflictLogDestAndTable
3)
GetConflictLogTableInfo:
+ *log_dest = GetLogDestination(MySubscription->conflictlogdest);
+ conflictlogrelid = MySubscription->conflictlogrelid;
+
+ /* If destination is 'log' only, no table to open. */
+ if (*log_dest == CONFLICT_LOG_DEST_LOG)
+ return NULL;
+
+ Assert(OidIsValid(conflictlogrelid));
We don't need to fetch conflictlogrelid until after 'if (*log_dest ==
CONFLICT_LOG_DEST_LOG)' check. We shall move it after the 'if' check.
4)
GetConflictLogTableInfo:
+ /* Conflict log table is dropped or not accessible. */
+ if (conflictlogrel == NULL)
+ ereport(WARNING,
+ (errcode(ERRCODE_UNDEFINED_TABLE),
+ errmsg("conflict log table with OID %u does not exist",
+ conflictlogrelid)));
Shall we replace it with elog(ERROR)? IMO, it should never happen and
if it happens, we should raise it as an internal error as we do for
various other cases.
5)
ReportApplyConflict():
Currently the function structure is:
/* Insert to table if destination is 'table' or 'all' */
if ((dest & CONFLICT_LOG_DEST_TABLE) != 0)
/* Decide what detail to show in server logs. */
if ((dest & CONFLICT_LOG_DEST_LOG) != 0)
else <table-only: put reduced info in log>
It will be good to make it:
/*
* Insert to table if destination is 'table' or 'all' and
* also log the error msg to serverlog
*/
if ((dest & CONFLICT_LOG_DEST_TABLE) != 0)
{...}
else <CONFLICT_LOG_DEST_LOG case>
{log complete detail}
6)
tuple_table_slot_to_indextup_json:
+ tuple = heap_form_tuple(tupdesc, values, isnull);
Do we need to do: heap_freetuple at the end?
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-08 09:21 shveta malik <shveta.malik@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
1 sibling, 0 replies; 912+ messages in thread
From: shveta malik @ 2026-01-08 09:21 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; Peter Smith <smithpb2250@gmail.com>; shveta malik <shveta.malik@gmail.com>
On Thu, Jan 8, 2026 at 12:29 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> On Wed, Jan 7, 2026 at 12:12 PM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > Hi Dilip,
> > Please find a few comments on v19-001:
> >
>
> Please find a few comments for v19-002's part I have reviewed so far:
>
> 1)
> ReportApplyConflict:
> + if ((dest & CONFLICT_LOG_DEST_TABLE) != 0)
> + if ((dest & CONFLICT_LOG_DEST_LOG) != 0)
>
> We can use IsSet everywhere
>
> 2)
> GetConflictLogTableInfo
> This function gets dest and opens table, shall we rename to :
> GetConflictLogDestAndTable
>
> 3)
> GetConflictLogTableInfo:
> + *log_dest = GetLogDestination(MySubscription->conflictlogdest);
> + conflictlogrelid = MySubscription->conflictlogrelid;
> +
> + /* If destination is 'log' only, no table to open. */
> + if (*log_dest == CONFLICT_LOG_DEST_LOG)
> + return NULL;
> +
> + Assert(OidIsValid(conflictlogrelid));
>
> We don't need to fetch conflictlogrelid until after 'if (*log_dest ==
> CONFLICT_LOG_DEST_LOG)' check. We shall move it after the 'if' check.
>
> 4)
> GetConflictLogTableInfo:
> + /* Conflict log table is dropped or not accessible. */
> + if (conflictlogrel == NULL)
> + ereport(WARNING,
> + (errcode(ERRCODE_UNDEFINED_TABLE),
> + errmsg("conflict log table with OID %u does not exist",
> + conflictlogrelid)));
>
> Shall we replace it with elog(ERROR)? IMO, it should never happen and
> if it happens, we should raise it as an internal error as we do for
> various other cases.
>
>
> 5)
> ReportApplyConflict():
>
> Currently the function structure is:
>
> /* Insert to table if destination is 'table' or 'all' */
> if ((dest & CONFLICT_LOG_DEST_TABLE) != 0)
>
> /* Decide what detail to show in server logs. */
> if ((dest & CONFLICT_LOG_DEST_LOG) != 0)
> else <table-only: put reduced info in log>
>
>
> It will be good to make it:
>
> /*
> * Insert to table if destination is 'table' or 'all' and
> * also log the error msg to serverlog
> */
> if ((dest & CONFLICT_LOG_DEST_TABLE) != 0)
> {...}
> else <CONFLICT_LOG_DEST_LOG case>
> {log complete detail}
>
>
> 6)
> tuple_table_slot_to_indextup_json:
> + tuple = heap_form_tuple(tupdesc, values, isnull);
>
> Do we need to do: heap_freetuple at the end?
>
Dilip, few more comments on 002:
7)
+$node_subscriber->safe_psql(
+ 'postgres', qq[
+ CREATE TABLE conf_tab_2 (a int PRIMARY KEY, b int, c int,
unique(a,b)) PARTITION BY RANGE (a);
+ CREATE TABLE conf_tab_2_p1 PARTITION OF conf_tab_2 FOR VALUES FROM
(MINVALUE) TO (100);
+]);
+
I don't see conf_tab_2 being used in tests.
8)
+##################################################
+# INSERT data on Pub and Sub
+##################################################
+
+# Insert data in the publisher table
+$node_publisher->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (1,1,1);");
+
+# Insert data in the subscriber table
+$node_subscriber->safe_psql('postgres',
+ "INSERT INTO conf_tab VALUES (2,2,2), (3,3,3), (4,4,4);");
Do we really need this? IIUC, we are not using the data inserted here
for any tests.
9)
+# Test conflict insertion into the internal conflict log table
Shall we mention insert_exists test like you have mentioned for subsequent tests
10)
+# CASE 3: Switching Destination to 'log' (Server Log Verification)
No CASE 1 and CASE 2 anywhere above. So term 'CASE 3' can be removed.
11)
+# Final cleanup for subsequent bidirectional tests in the script
+$node_subscriber->safe_psql('postgres', "TRUNCATE conf_tab;");
There are no bidirectional tests.
12)
Do we even need a new file, or can we adjust these in
t/035_conflicts.pl? What do you think?
~~
Overall, tests can be reviewed and structured better. When you get a
chance, please review it once yourself too.
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-09 03:00 Peter Smith <smithpb2250@gmail.com>
parent: vignesh C <vignesh21@gmail.com>
2 siblings, 1 reply; 912+ messages in thread
From: Peter Smith @ 2026-01-09 03:00 UTC (permalink / raw)
To: vignesh C <vignesh21@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Dilip Kumar <dilipbalaut@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Hi Dilip.
Here are some review comments for the v19-0002 (code only, not tests).
======
src/backend/replication/logical/conflict.c
1.
+/* Schema for the elements within the 'local_conflicts' JSON array */
+static const ConflictLogColumnDef LocalConflictSchema[] =
+{
+ { .attname = "xid", .atttypid = XIDOID },
+ { .attname = "commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "origin", .atttypid = TEXTOID },
+ { .attname = "key", .atttypid = JSONOID },
+ { .attname = "tuple", .atttypid = JSONOID }
+};
Maybe this only needs to be used in this module, but I still thought
LocalConflictSchema[] (and the MAX_LOCAL_CONFLICT_INFO_ATTRS) belongs
more naturally alongside the other ConflictLogSchema[] (e.g. in
conflict.h)
~~~
2.
+
+#define MAX_LOCAL_CONFLICT_INFO_ATTRS \
+ (sizeof(LocalConflictSchema) / sizeof(LocalConflictSchema[0]))
+
how about:
#define MAX_LOCAL_CONFLICT_INFO_ATTRS lengthof(LocalConflictSchema)
~~~
ReportApplyConflict:
3.
+ /* Insert to table if destination is 'table' or 'all' */
+ if ((dest & CONFLICT_LOG_DEST_TABLE) != 0)
I think it is unnecessary to even mention 'all'. The bitmasks tell
everything you need to know.
SUGGESTION
Insert to table if requested.
~
4.
There's a slightly convoluted if;if/else with these bitmasks here, but
I think Shveta already suggested the same change as what I was
thinking.
~~~
GetConflictLogTableInfo:
5.
+ *log_dest = GetLogDestination(MySubscription->conflictlogdest);
+ conflictlogrelid = MySubscription->conflictlogrelid;
+
+ /* If destination is 'log' only, no table to open. */
+ if (*log_dest == CONFLICT_LOG_DEST_LOG)
+ return NULL;
I think checking and mentioning 'log' here is not future-proof for
when there might be more kinds of destinations. We just want to return
when the user doesn't want a 'table'. Use the bitmasks to do that.
SUGGESTION
/* Quick exit if a conflict log table was not requested. */
if ((*logdest & CONFLICT_LOG_DEST_TABLE) == 0)
return NULL;
~~~
build_conflict_tupledesc:
6.
+static TupleDesc
+build_conflict_tupledesc(void)
+{
Missing function comment. There used to be one (??).
~~~
build_local_conflicts_json_array:
7.
+ json_datum_array = (Datum *) palloc(num_conflicts * sizeof(Datum));
+ json_null_array = (bool *) palloc0(num_conflicts * sizeof(bool));
I may have already asked this same question in a previous review, but
shouldn't these be using those new palloc_array and palloc0_array
macros?
======
src/include/replication/conflict.h
8.
-/* Define the count using the array size */
#define MAX_CONFLICT_ATTR_NUM (sizeof(ConflictLogSchema) /
sizeof(ConflictLogSchema[0]))
This change appears to be in the wrong patch. AFAICT it belonged in 0001.
======
Kind Regards,
Peter Smith.
Fujitsu Australia
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-09 07:12 vignesh C <vignesh21@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: vignesh C @ 2026-01-09 07:12 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Mon, 5 Jan 2026 at 15:13, Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> Here is the updated version of patch
I would like to suggest renaming the newly introduced system schema
from pg_conflict to pg_logical or something in lines of logical
replication.
Although the immediate use case is conflict logging, the schema is
fundamentally part of the logical replication subsystem. A broader
name such as pg_logical provides a more appropriate and future proof
namespace. In contrast, a feature-specific name like pg_conflict is
risky as logical replication evolves and could necessitate the
introduction of additional system schemas later. Looking ahead,
advanced features such as DDL replication or multi-node writes would
likely require metadata for cluster topology of multiple nodes, leader
state, peer discovery, and resolution policies for node failure. This
type of replication specific metadata would naturally fit under a
pg_logical system schema rather than pg_catalog.
Finally, adopting pg_logical would leave open the possibility of
logically grouping existing logical replication catalogs and views
(publications, subscriptions, and slot related information) under a
single subsystem namespace, instead of having them scattered across
pg_catalog.
Regards,
Vignesh
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-09 11:18 Dilip Kumar <dilipbalaut@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2026-01-09 11:18 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; Peter Smith <smithpb2250@gmail.com>
On Thu, Jan 8, 2026 at 12:30 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> On Wed, Jan 7, 2026 at 12:12 PM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > Hi Dilip,
> > Please find a few comments on v19-001:
> >
>
> Please find a few comments for v19-002's part I have reviewed so far:
>
> 1)
> ReportApplyConflict:
> + if ((dest & CONFLICT_LOG_DEST_TABLE) != 0)
> + if ((dest & CONFLICT_LOG_DEST_LOG) != 0)
>
> We can use IsSet everywhere
IMHO in subscriptioncmd.c we very frequently use the bitwise operation
so it has IsSet declared, I am not really sure do we want to define
IsSet in this function as we are using it just 2 places.
> 2)
> GetConflictLogTableInfo
> This function gets dest and opens table, shall we rename to :
> GetConflictLogDestAndTable
Yeah make sense.
> 3)
> GetConflictLogTableInfo:
> + *log_dest = GetLogDestination(MySubscription->conflictlogdest);
> + conflictlogrelid = MySubscription->conflictlogrelid;
> +
> + /* If destination is 'log' only, no table to open. */
> + if (*log_dest == CONFLICT_LOG_DEST_LOG)
> + return NULL;
> +
> + Assert(OidIsValid(conflictlogrelid));
>
> We don't need to fetch conflictlogrelid until after 'if (*log_dest ==
> CONFLICT_LOG_DEST_LOG)' check. We shall move it after the 'if' check.
Done
> 4)
> GetConflictLogTableInfo:
> + /* Conflict log table is dropped or not accessible. */
> + if (conflictlogrel == NULL)
> + ereport(WARNING,
> + (errcode(ERRCODE_UNDEFINED_TABLE),
> + errmsg("conflict log table with OID %u does not exist",
> + conflictlogrelid)));
>
> Shall we replace it with elog(ERROR)? IMO, it should never happen and
> if it happens, we should raise it as an internal error as we do for
> various other cases.
Right, now its internal table so we should make it elog(ERROR)
>
> 5)
> ReportApplyConflict():
>
> Currently the function structure is:
>
> /* Insert to table if destination is 'table' or 'all' */
> if ((dest & CONFLICT_LOG_DEST_TABLE) != 0)
>
> /* Decide what detail to show in server logs. */
> if ((dest & CONFLICT_LOG_DEST_LOG) != 0)
> else <table-only: put reduced info in log>
>
>
> It will be good to make it:
>
> /*
> * Insert to table if destination is 'table' or 'all' and
> * also log the error msg to serverlog
> */
> if ((dest & CONFLICT_LOG_DEST_TABLE) != 0)
> {...}
> else <CONFLICT_LOG_DEST_LOG case>
> {log complete detail}
I did not understand this, I mean we can not put the "log complete
detail" case in the else part, because we might have to log the
complete detail along with the table if the destination is "all", are
you suggesting something else?
>
> 6)
> tuple_table_slot_to_indextup_json:
> + tuple = heap_form_tuple(tupdesc, values, isnull);
>
> Do we need to do: heap_freetuple at the end?
Yeah better we do that, instead of assuming short lived memory context.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-09 13:42 Dilip Kumar <dilipbalaut@gmail.com>
parent: vignesh C <vignesh21@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2026-01-09 13:42 UTC (permalink / raw)
To: vignesh C <vignesh21@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Fri, Jan 9, 2026 at 12:42 PM vignesh C <vignesh21@gmail.com> wrote:
>
> On Mon, 5 Jan 2026 at 15:13, Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > Here is the updated version of patch
>
> I would like to suggest renaming the newly introduced system schema
> from pg_conflict to pg_logical or something in lines of logical
> replication.
> Although the immediate use case is conflict logging, the schema is
> fundamentally part of the logical replication subsystem. A broader
> name such as pg_logical provides a more appropriate and future proof
> namespace. In contrast, a feature-specific name like pg_conflict is
> risky as logical replication evolves and could necessitate the
> introduction of additional system schemas later. Looking ahead,
> advanced features such as DDL replication or multi-node writes would
> likely require metadata for cluster topology of multiple nodes, leader
> state, peer discovery, and resolution policies for node failure. This
> type of replication specific metadata would naturally fit under a
> pg_logical system schema rather than pg_catalog.
>
> Finally, adopting pg_logical would leave open the possibility of
> logically grouping existing logical replication catalogs and views
> (publications, subscriptions, and slot related information) under a
> single subsystem namespace, instead of having them scattered across
> pg_catalog.
I agree with this analysis of making it future proof what others think
about this? Although we might not be clear now what permission
differences would be needed for future metadata tables compared to
conflict tables, those could be managed if we get the generic name.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-10 06:49 Dilip Kumar <dilipbalaut@gmail.com>
parent: Peter Smith <smithpb2250@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2026-01-10 06:49 UTC (permalink / raw)
To: Peter Smith <smithpb2250@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Fri, Jan 9, 2026 at 8:30 AM Peter Smith <smithpb2250@gmail.com> wrote:
>
> Hi Dilip.
>
> Here are some review comments for the v19-0002 (code only, not tests).
>
> ======
> src/backend/replication/logical/conflict.c
>
> 1.
> +/* Schema for the elements within the 'local_conflicts' JSON array */
> +static const ConflictLogColumnDef LocalConflictSchema[] =
> +{
> + { .attname = "xid", .atttypid = XIDOID },
> + { .attname = "commit_ts", .atttypid = TIMESTAMPTZOID },
> + { .attname = "origin", .atttypid = TEXTOID },
> + { .attname = "key", .atttypid = JSONOID },
> + { .attname = "tuple", .atttypid = JSONOID }
> +};
>
> Maybe this only needs to be used in this module, but I still thought
> LocalConflictSchema[] (and the MAX_LOCAL_CONFLICT_INFO_ATTRS) belongs
> more naturally alongside the other ConflictLogSchema[] (e.g. in
> conflict.h)
Since this is used only in conflict.c, my personal preference to keep
it in the .c file
> ~~~
>
> 2.
> +
> +#define MAX_LOCAL_CONFLICT_INFO_ATTRS \
> + (sizeof(LocalConflictSchema) / sizeof(LocalConflictSchema[0]))
> +
>
> how about:
>
> #define MAX_LOCAL_CONFLICT_INFO_ATTRS lengthof(LocalConflictSchema)
Make sense
> ~~~
>
> ReportApplyConflict:
>
> 3.
> + /* Insert to table if destination is 'table' or 'all' */
> + if ((dest & CONFLICT_LOG_DEST_TABLE) != 0)
>
> I think it is unnecessary to even mention 'all'. The bitmasks tell
> everything you need to know.
>
> SUGGESTION
> Insert to table if requested.
Done
> ~
>
> 4.
> There's a slightly convoluted if;if/else with these bitmasks here, but
> I think Shveta already suggested the same change as what I was
> thinking.
So now we have this, which is first handling the table case and
handling the LOG case separately, because irrespective of whether we
want to insert into the table or not we need to check what we need to
log? What's your suggestion for this?
if ((dest & CONFLICT_LOG_DEST_TABLE) != 0)
{
<Insert into table>
}
if ((dest & CONFLICT_LOG_DEST_LOG) != 0)
{
<write full log>
}
else
{
<write minimal log>
}
> ~~~
>
> GetConflictLogTableInfo:
>
> 5.
> + *log_dest = GetLogDestination(MySubscription->conflictlogdest);
> + conflictlogrelid = MySubscription->conflictlogrelid;
> +
> + /* If destination is 'log' only, no table to open. */
> + if (*log_dest == CONFLICT_LOG_DEST_LOG)
> + return NULL;
>
> I think checking and mentioning 'log' here is not future-proof for
> when there might be more kinds of destinations. We just want to return
> when the user doesn't want a 'table'. Use the bitmasks to do that.
>
> SUGGESTION
> /* Quick exit if a conflict log table was not requested. */
> if ((*logdest & CONFLICT_LOG_DEST_TABLE) == 0)
> return NULL;
Make sense
> ~~~
>
> build_conflict_tupledesc:
>
> 6.
> +static TupleDesc
> +build_conflict_tupledesc(void)
> +{
>
> Missing function comment. There used to be one (??).
Yeah it got lost in some of the refactoring.
> ~~~
>
> build_local_conflicts_json_array:
>
> 7.
> + json_datum_array = (Datum *) palloc(num_conflicts * sizeof(Datum));
> + json_null_array = (bool *) palloc0(num_conflicts * sizeof(bool));
>
> I may have already asked this same question in a previous review, but
> shouldn't these be using those new palloc_array and palloc0_array
> macros?
Done.
> ======
> src/include/replication/conflict.h
>
> 8.
> -/* Define the count using the array size */
> #define MAX_CONFLICT_ATTR_NUM (sizeof(ConflictLogSchema) /
> sizeof(ConflictLogSchema[0]))
>
> This change appears to be in the wrong patch. AFAICT it belonged in 0001.
Fixed.
Will shared the updated patch after fixing other comments from Shveta
as well, thanks for the review.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-10 13:15 Dilip Kumar <dilipbalaut@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 5 replies; 912+ messages in thread
From: Dilip Kumar @ 2026-01-10 13:15 UTC (permalink / raw)
To: Peter Smith <smithpb2250@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Sat, Jan 10, 2026 at 12:19 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Fri, Jan 9, 2026 at 8:30 AM Peter Smith <smithpb2250@gmail.com> wrote:
> >
> > Hi Dilip.
> >
> > Here are some review comments for the v19-0002 (code only, not tests).
> >
> > ======
> > src/backend/replication/logical/conflict.c
> >
> > 1.
> > +/* Schema for the elements within the 'local_conflicts' JSON array */
> > +static const ConflictLogColumnDef LocalConflictSchema[] =
> > +{
> > + { .attname = "xid", .atttypid = XIDOID },
> > + { .attname = "commit_ts", .atttypid = TIMESTAMPTZOID },
> > + { .attname = "origin", .atttypid = TEXTOID },
> > + { .attname = "key", .atttypid = JSONOID },
> > + { .attname = "tuple", .atttypid = JSONOID }
> > +};
> >
> > Maybe this only needs to be used in this module, but I still thought
> > LocalConflictSchema[] (and the MAX_LOCAL_CONFLICT_INFO_ATTRS) belongs
> > more naturally alongside the other ConflictLogSchema[] (e.g. in
> > conflict.h)
>
> Since this is used only in conflict.c, my personal preference to keep
> it in the .c file
>
> > ~~~
> >
> > 2.
> > +
> > +#define MAX_LOCAL_CONFLICT_INFO_ATTRS \
> > + (sizeof(LocalConflictSchema) / sizeof(LocalConflictSchema[0]))
> > +
> >
> > how about:
> >
> > #define MAX_LOCAL_CONFLICT_INFO_ATTRS lengthof(LocalConflictSchema)
>
> Make sense
> > ~~~
> >
> > ReportApplyConflict:
> >
> > 3.
> > + /* Insert to table if destination is 'table' or 'all' */
> > + if ((dest & CONFLICT_LOG_DEST_TABLE) != 0)
> >
> > I think it is unnecessary to even mention 'all'. The bitmasks tell
> > everything you need to know.
> >
> > SUGGESTION
> > Insert to table if requested.
>
> Done
>
> > ~
> >
> > 4.
> > There's a slightly convoluted if;if/else with these bitmasks here, but
> > I think Shveta already suggested the same change as what I was
> > thinking.
>
> So now we have this, which is first handling the table case and
> handling the LOG case separately, because irrespective of whether we
> want to insert into the table or not we need to check what we need to
> log? What's your suggestion for this?
>
> if ((dest & CONFLICT_LOG_DEST_TABLE) != 0)
> {
> <Insert into table>
> }
>
> if ((dest & CONFLICT_LOG_DEST_LOG) != 0)
> {
> <write full log>
> }
> else
> {
> <write minimal log>
> }
>
>
>
> > ~~~
> >
> > GetConflictLogTableInfo:
> >
> > 5.
> > + *log_dest = GetLogDestination(MySubscription->conflictlogdest);
> > + conflictlogrelid = MySubscription->conflictlogrelid;
> > +
> > + /* If destination is 'log' only, no table to open. */
> > + if (*log_dest == CONFLICT_LOG_DEST_LOG)
> > + return NULL;
> >
> > I think checking and mentioning 'log' here is not future-proof for
> > when there might be more kinds of destinations. We just want to return
> > when the user doesn't want a 'table'. Use the bitmasks to do that.
> >
> > SUGGESTION
> > /* Quick exit if a conflict log table was not requested. */
> > if ((*logdest & CONFLICT_LOG_DEST_TABLE) == 0)
> > return NULL;
>
> Make sense
>
> > ~~~
> >
> > build_conflict_tupledesc:
> >
> > 6.
> > +static TupleDesc
> > +build_conflict_tupledesc(void)
> > +{
> >
> > Missing function comment. There used to be one (??).
>
> Yeah it got lost in some of the refactoring.
>
> > ~~~
> >
> > build_local_conflicts_json_array:
> >
> > 7.
> > + json_datum_array = (Datum *) palloc(num_conflicts * sizeof(Datum));
> > + json_null_array = (bool *) palloc0(num_conflicts * sizeof(bool));
> >
> > I may have already asked this same question in a previous review, but
> > shouldn't these be using those new palloc_array and palloc0_array
> > macros?
>
> Done.
>
> > ======
> > src/include/replication/conflict.h
> >
> > 8.
> > -/* Define the count using the array size */
> > #define MAX_CONFLICT_ATTR_NUM (sizeof(ConflictLogSchema) /
> > sizeof(ConflictLogSchema[0]))
> >
> > This change appears to be in the wrong patch. AFAICT it belonged in 0001.
>
> Fixed.
>
> Will shared the updated patch after fixing other comments from Shveta
> as well, thanks for the review.
>
Here is the updated patch
1. fixes all the agreed comments from Peter and Shveta.
2. also for dependency between the clt and subscription I have
improvised the comments, I mean now we don't need that dependency to
protect the clt from getting dropped as that is taken care by internal
schema, we still maintain the dependency considering the same thing is
done for the toast table and these tables are internally
created/dropped along with subscription so conceptually it makes sense
to maintain the internal dependency and its also looks fine to drop it
explicitly using dependency dependency drop function i.e.
performDeletion() as we are doing similar things in other cases like
"Identity Columns".
3. I tried to merge the test cases in the existing 035_conflict.pl, I
modified the create subscription to log into both table and log and
then extended some of the existing test cases to validate the table
logging as well. If we think this looks fine then we can update a few
more test cases to validate from the table or maybe all the test
cases?
--
Regards,
Dilip Kumar
Google
Attachments:
[application/octet-stream] v20-0002-Implement-the-conflict-insertion-infrastructure-.patch (28.5K, ../../CAFiTN-vj8NTm9w_L2XdhxJCub_RZw__YVUgfXa1B1kJzJctRNw@mail.gmail.com/2-v20-0002-Implement-the-conflict-insertion-infrastructure-.patch)
download | inline diff:
From a81f0235d976834842c9a337dfece683cde8d18f Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Fri, 9 Jan 2026 15:55:55 +0530
Subject: [PATCH v20 2/3] Implement the conflict insertion infrastructure for
the conflict log table
This patch introduces the core logic to populate the conflict log table whenever
a logical replication conflict is detected. It captures the remote transaction
details along with the corresponding local state at the time of the conflict.
Handling Multi-row Conflicts: A single remote tuple may conflict with multiple
local tuples (e.g., in the case of multiple_unique_conflicts). To handle this,
the infrastructure creates a single row in the conflict log table for each
remote tuple. The details of all conflicting local rows are aggregated into a
single JSON array in the local_conflicts column.
The JSON array uses the following structured format:
[ { "xid": "1001", "commit_ts": "2025-12-25 10:00:00+05:30", "origin": "node_1",
"key": {"id": 1}, "tuple": {"id": 1, "val": "old_data"} }, ... ]
Example of querying the structured conflict data:
SELECT remote_xid, relname, remote_origin, local_conflicts[1] ->> 'xid' AS local_xid,
local_conflicts[1] ->> 'tuple' AS local_tuple
FROM myschema.conflict_log_history2;
remote_xid | relname | remote_origin | local_xid | local_tuple
------------+----------+---------------+-----------+---------------------
760 | test | pg_16406 | 771 | {"a":1,"b":10}
765 | conf_tab | pg_16406 | 775 | {"a":2,"b":2,"c":2}
---
src/backend/replication/logical/conflict.c | 551 +++++++++++++++++++--
src/backend/replication/logical/launcher.c | 1 +
src/backend/replication/logical/worker.c | 31 +-
src/include/replication/conflict.h | 4 +-
src/include/replication/worker_internal.h | 7 +
src/test/subscription/t/035_conflicts.pl | 53 +-
6 files changed, 602 insertions(+), 45 deletions(-)
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 93222ee3b88..ee69fe787aa 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -15,13 +15,20 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/tableam.h"
+#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "pgstat.h"
#include "replication/conflict.h"
#include "replication/worker_internal.h"
#include "storage/lmgr.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/lsyscache.h"
+#include "utils/pg_lsn.h"
+#include "utils/jsonb.h"
static const char *const ConflictTypeNames[] = {
[CT_INSERT_EXISTS] = "insert_exists",
@@ -34,6 +41,18 @@ static const char *const ConflictTypeNames[] = {
[CT_MULTIPLE_UNIQUE_CONFLICTS] = "multiple_unique_conflicts"
};
+/* Schema for the elements within the 'local_conflicts' JSON array */
+static const ConflictLogColumnDef LocalConflictSchema[] =
+{
+ { .attname = "xid", .atttypid = XIDOID },
+ { .attname = "commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "origin", .atttypid = TEXTOID },
+ { .attname = "key", .atttypid = JSONOID },
+ { .attname = "tuple", .atttypid = JSONOID }
+};
+
+#define MAX_LOCAL_CONFLICT_INFO_ATTRS lengthof(LocalConflictSchema)
+
static int errcode_apply_conflict(ConflictType type);
static void errdetail_apply_conflict(EState *estate,
ResultRelInfo *relinfo,
@@ -50,8 +69,27 @@ static char *build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *localslot,
TupleTableSlot *remoteslot,
Oid indexoid);
+static void build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull);
static char *build_index_value_desc(EState *estate, Relation localrel,
TupleTableSlot *slot, Oid indexoid);
+static Datum tuple_table_slot_to_json_datum(TupleTableSlot *slot);
+static Datum tuple_table_slot_to_indextup_json(EState *estate,
+ Relation localrel,
+ Oid replica_index,
+ TupleTableSlot *slot);
+static TupleDesc build_conflict_tupledesc(void);
+static Datum build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples);
+static void prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot);
/*
* Get the xmin and commit timestamp data (origin and timestamp) associated
@@ -105,30 +143,83 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
ConflictType type, TupleTableSlot *searchslot,
TupleTableSlot *remoteslot, List *conflicttuples)
{
- Relation localrel = relinfo->ri_RelationDesc;
- StringInfoData err_detail;
+ Relation localrel = relinfo->ri_RelationDesc;
+ ConflictLogDest dest;
+ Relation conflictlogrel;
- initStringInfo(&err_detail);
+ /*
+ * Get both the conflict log destination and the opened conflict log
+ * relation for insertion.
+ */
+ conflictlogrel = GetConflictLogDestAndTable(&dest);
- /* Form errdetail message by combining conflicting tuples information. */
- foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
- errdetail_apply_conflict(estate, relinfo, type, searchslot,
- conflicttuple->slot, remoteslot,
- conflicttuple->indexoid,
- conflicttuple->xmin,
- conflicttuple->origin,
- conflicttuple->ts,
- &err_detail);
+ /* Insert to table if requested. */
+ if ((dest & CONFLICT_LOG_DEST_TABLE) != 0)
+ {
+ Assert(conflictlogrel != NULL);
+
+ /*
+ * Prepare the conflict log tuple. If the error level is below ERROR,
+ * insert it immediately. Otherwise, defer the insertion to a new
+ * transaction after the current one aborts, ensuring the insertion of
+ * the log tuple is not rolled back.
+ */
+ prepare_conflict_log_tuple(estate,
+ relinfo->ri_RelationDesc,
+ conflictlogrel,
+ type,
+ searchslot,
+ conflicttuples,
+ remoteslot);
+ if (elevel < ERROR)
+ InsertConflictLogTuple(conflictlogrel);
+
+ table_close(conflictlogrel, RowExclusiveLock);
+ }
pgstat_report_subscription_conflict(MySubscription->oid, type);
- ereport(elevel,
- errcode_apply_conflict(type),
- errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
- get_namespace_name(RelationGetNamespace(localrel)),
- RelationGetRelationName(localrel),
- ConflictTypeNames[type]),
- errdetail_internal("%s", err_detail.data));
+ /* Decide what detail to show in server logs. */
+ if ((dest & CONFLICT_LOG_DEST_LOG) != 0)
+ {
+ StringInfoData err_detail;
+
+ initStringInfo(&err_detail);
+
+ /* Form errdetail message by combining conflicting tuples information. */
+ foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
+ errdetail_apply_conflict(estate, relinfo, type, searchslot,
+ conflicttuple->slot, remoteslot,
+ conflicttuple->indexoid,
+ conflicttuple->xmin,
+ conflicttuple->origin,
+ conflicttuple->ts,
+ &err_detail);
+
+ /* Standard reporting with full internal details. */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail_internal("%s", err_detail.data));
+ }
+ else
+ {
+ /*
+ * 'table' only: Report the error msg but omit raw tuple data from
+ * server logs since it's already captured in the internal table.
+ */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail("Conflict details logged to internal table with OID %u.",
+ MySubscription->conflictlogrelid));
+ }
}
/*
@@ -162,6 +253,64 @@ InitConflictIndexes(ResultRelInfo *relInfo)
relInfo->ri_onConflictArbiterIndexes = uniqueIndexes;
}
+/*
+ * GetConflictLogDestAndTable
+ *
+ * Fetches conflict logging metadata from the cached MySubscription pointer.
+ * Sets the destination enum in *log_dest and, if applicable, opens and
+ * returns the relation handle for the internal log table.
+ */
+Relation
+GetConflictLogDestAndTable(ConflictLogDest *log_dest)
+{
+ Oid conflictlogrelid;
+ Relation conflictlogrel = NULL;
+
+ /*
+ * Convert the text log destination to the internal enum. MySubscription
+ * already contains the data from pg_subscription.
+ */
+ *log_dest = GetLogDestination(MySubscription->conflictlogdest);
+
+ /* Quick exit if a conflict log table was not requested. */
+ if ((*log_dest & CONFLICT_LOG_DEST_TABLE) == 0)
+ return NULL;
+
+ conflictlogrelid = MySubscription->conflictlogrelid;
+
+ Assert(OidIsValid(conflictlogrelid));
+
+ conflictlogrel = table_open(conflictlogrelid, RowExclusiveLock);
+ if (conflictlogrel == NULL)
+ elog(ERROR, "could not open conflict log table (OID %u)",
+ conflictlogrelid);
+
+ return conflictlogrel;
+}
+
+/*
+ * InsertConflictLogTuple
+ *
+ * Insert conflict log tuple into the conflict log table. It uses
+ * HEAP_INSERT_NO_LOGICAL to explicitly block logical decoding of the tuple
+ * inserted into the conflict log table.
+ */
+void
+InsertConflictLogTuple(Relation conflictlogrel)
+{
+ int options = HEAP_INSERT_NO_LOGICAL;
+
+ /* A valid tuple must be prepared and stored in MyLogicalRepWorker. */
+ Assert(MyLogicalRepWorker->conflict_log_tuple != NULL);
+
+ heap_insert(conflictlogrel, MyLogicalRepWorker->conflict_log_tuple,
+ GetCurrentCommandId(true), options, NULL);
+
+ /* Free conflict log tuple. */
+ heap_freetuple(MyLogicalRepWorker->conflict_log_tuple);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+}
+
/*
* Add SQLSTATE error code to the current conflict report.
*/
@@ -472,6 +621,40 @@ build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
return tuple_value.data;
}
+/*
+ * Helper function to extract the "raw" index key Datums and their null flags
+ * from a TupleTableSlot, given an already open index descriptor.
+ * This is the reusable core logic.
+ */
+static void
+build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull)
+{
+ TupleTableSlot *tableslot = slot;
+
+ /*
+ * If the slot is a virtual slot, copy it into a heap tuple slot as
+ * FormIndexDatum only works with heap tuple slots.
+ */
+ if (TTS_IS_VIRTUAL(slot))
+ {
+ /* Slot is created within the EState's tuple table */
+ tableslot = table_slot_create(localrel, &estate->es_tupleTable);
+ tableslot = ExecCopySlot(tableslot, slot);
+ }
+
+ /*
+ * Initialize ecxt_scantuple for potential use in FormIndexDatum
+ */
+ GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+
+ /* Form the index datums */
+ FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values,
+ isnull);
+}
+
/*
* Helper functions to construct a string describing the contents of an index
* entry. See BuildIndexValueDescription for details.
@@ -487,41 +670,325 @@ build_index_value_desc(EState *estate, Relation localrel, TupleTableSlot *slot,
Relation indexDesc;
Datum values[INDEX_MAX_KEYS];
bool isnull[INDEX_MAX_KEYS];
- TupleTableSlot *tableslot = slot;
- if (!tableslot)
+ if (!slot)
return NULL;
Assert(CheckRelationOidLockedByMe(indexoid, RowExclusiveLock, true));
indexDesc = index_open(indexoid, NoLock);
- /*
- * If the slot is a virtual slot, copy it into a heap tuple slot as
- * FormIndexDatum only works with heap tuple slots.
- */
- if (TTS_IS_VIRTUAL(slot))
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+
+ index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+
+ index_close(indexDesc, NoLock);
+
+ return index_value;
+}
+
+/*
+ * tuple_table_slot_to_json_datum
+ *
+ * Helper function to convert a TupleTableSlot to Jsonb.
+ */
+static Datum
+tuple_table_slot_to_json_datum(TupleTableSlot *slot)
+{
+ HeapTuple tuple;
+ Datum datum;
+ Datum json;
+
+ Assert(slot != NULL);
+
+ tuple = ExecCopySlotHeapTuple(slot);
+ datum = heap_copy_tuple_as_datum(tuple, slot->tts_tupleDescriptor);
+
+ json = DirectFunctionCall1(row_to_json, datum);
+ heap_freetuple(tuple);
+
+ return json;
+}
+
+/*
+ * tuple_table_slot_to_indextup_json
+ *
+ * Fetch replica identity key from the tuple table slot and convert into a
+ * jsonb datum.
+ */
+static Datum
+tuple_table_slot_to_indextup_json(EState *estate, Relation localrel,
+ Oid indexid, TupleTableSlot *slot)
+{
+ Relation indexDesc;
+ Datum values[INDEX_MAX_KEYS];
+ bool isnull[INDEX_MAX_KEYS];
+ HeapTuple tuple;
+ TupleDesc tupdesc;
+ Datum datum;
+
+ Assert(slot != NULL);
+
+ Assert(CheckRelationOidLockedByMe(indexid, RowExclusiveLock, true));
+
+ indexDesc = index_open(indexid, NoLock);
+
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+ tupdesc = RelationGetDescr(indexDesc);
+
+ /* Bless the tupdesc so it can be looked up by row_to_json. */
+ BlessTupleDesc(tupdesc);
+
+ /* Form the replica identity tuple. */
+ tuple = heap_form_tuple(tupdesc, values, isnull);
+ datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ index_close(indexDesc, NoLock);
+ heap_freetuple(tuple);
+
+ /* Convert to a JSONB datum. */
+ return DirectFunctionCall1(row_to_json, datum);
+}
+
+/*
+ * build_conflict_tupledesc
+ *
+ * Build and bless a tuple descriptor for the internal conflict log table
+ * based on the predefined LocalConflictSchema.
+ */
+static TupleDesc
+build_conflict_tupledesc(void)
+{
+ TupleDesc tupdesc;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ for (int i = 0; i < MAX_LOCAL_CONFLICT_INFO_ATTRS; i++)
+ TupleDescInitEntry(tupdesc, (AttrNumber) (i + 1),
+ LocalConflictSchema[i].attname,
+ LocalConflictSchema[i].atttypid,
+ -1, 0);
+
+ BlessTupleDesc(tupdesc);
+
+ return tupdesc;
+}
+
+/*
+ * Builds the local conflicts JSONB array column from the list of
+ * ConflictTupleInfo objects.
+ *
+ * Example output structure:
+ * [ { "xid": "1001", "commit_ts": "...", "origin": "...", "tuple": {...} }, ... ]
+ */
+static Datum
+build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples)
+{
+ ListCell *lc;
+ List *json_datums = NIL; /* List to hold the row_to_json results (type json) */
+ Datum *json_datum_array;
+ bool *json_null_array;
+ Datum json_array_datum;
+ int num_conflicts;
+ int i;
+ int16 typlen;
+ bool typbyval;
+ char typalign;
+ TupleDesc tupdesc;
+
+ /* Build local conflicts tuple descriptor. */
+ tupdesc = build_conflict_tupledesc();
+
+ /* Process local conflict tuple list and prepare an array of JSON. */
+ foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
{
- tableslot = table_slot_create(localrel, &estate->es_tupleTable);
- tableslot = ExecCopySlot(tableslot, slot);
+ Datum values[MAX_LOCAL_CONFLICT_INFO_ATTRS] = {0};
+ bool nulls[MAX_LOCAL_CONFLICT_INFO_ATTRS] = {0};
+ char *origin_name = NULL;
+ HeapTuple tuple;
+ Datum json_datum;
+ int attno;
+
+ attno = 0;
+ values[attno++] = TransactionIdGetDatum(conflicttuple->xmin);
+
+ if (conflicttuple->ts)
+ values[attno++] = TimestampTzGetDatum(conflicttuple->ts);
+ else
+ nulls[attno++] = true;
+
+ if (conflicttuple->origin != InvalidRepOriginId)
+ replorigin_by_oid(conflicttuple->origin, true, &origin_name);
+
+ /* Store empty string if origin name for the tuple is NULL. */
+ if (origin_name != NULL)
+ values[attno++] = CStringGetTextDatum(origin_name);
+ else
+ nulls[attno++] = true;
+
+ /*
+ * Add the conflicting key values in the case of a unique constraint
+ * violation.
+ */
+ if (conflict_type == CT_INSERT_EXISTS ||
+ conflict_type == CT_UPDATE_EXISTS ||
+ conflict_type == CT_MULTIPLE_UNIQUE_CONFLICTS)
+ {
+ Oid indexoid = conflicttuple->indexoid;
+
+ Assert(OidIsValid(indexoid) && conflicttuple->slot &&
+ CheckRelationOidLockedByMe(indexoid, RowExclusiveLock,
+ true));
+ values[attno++] =
+ tuple_table_slot_to_indextup_json(estate, rel,
+ indexoid,
+ conflicttuple->slot);
+ }
+ else
+ nulls[attno++] = true;
+
+ /* Convert conflicting tuple to JSON datum. */
+ if (conflicttuple->slot)
+ values[attno] = tuple_table_slot_to_json_datum(conflicttuple->slot);
+ else
+ nulls[attno] = true;
+
+ Assert(attno + 1 == MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ tuple = heap_form_tuple(tupdesc, values, nulls);
+
+ json_datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ /*
+ * Build the higher level JSON datum in format described in function
+ * header.
+ */
+ json_datum = DirectFunctionCall1(row_to_json, json_datum);
+
+ /* Done with the temporary tuple. */
+ heap_freetuple(tuple);
+
+ /* Add to the array element. */
+ json_datums = lappend(json_datums, (void *) json_datum);
}
- /*
- * Initialize ecxt_scantuple for potential use in FormIndexDatum when
- * index expressions are present.
- */
- GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+ num_conflicts = list_length(json_datums);
- /*
- * The values/nulls arrays passed to BuildIndexValueDescription should be
- * the results of FormIndexDatum, which are the "raw" input to the index
- * AM.
- */
- FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values, isnull);
+ json_datum_array = (Datum *) palloc_array(Datum, num_conflicts);
+ json_null_array = (bool *) palloc0_array(bool, num_conflicts);
- index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+ i = 0;
+ foreach(lc, json_datums)
+ {
+ json_datum_array[i] = (Datum) lfirst(lc);
+ i++;
+ }
- index_close(indexDesc, NoLock);
+ /* Construct the json[] array Datum. */
+ get_typlenbyvalalign(JSONOID, &typlen, &typbyval, &typalign);
+ json_array_datum = PointerGetDatum(construct_array(json_datum_array,
+ num_conflicts,
+ JSONOID,
+ typlen,
+ typbyval,
+ typalign));
+ pfree(json_datum_array);
+ pfree(json_null_array);
+
+ return json_array_datum;
+}
- return index_value;
+/*
+ * prepare_conflict_log_tuple
+ *
+ * This routine prepares a tuple detailing a conflict encountered during
+ * logical replication. The prepared tuple will be stored in
+ * MyLogicalRepWorker->conflict_log_tuple which should be inserted into the
+ * conflict log table by calling InsertConflictLogTuple.
+ */
+static void
+prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot)
+{
+ Datum values[MAX_CONFLICT_ATTR_NUM] = {0};
+ bool nulls[MAX_CONFLICT_ATTR_NUM] = {0};
+ int attno;
+ char *remote_origin = NULL;
+ MemoryContext oldctx;
+
+ Assert(MyLogicalRepWorker->conflict_log_tuple == NULL);
+
+ /* Populate the values and nulls arrays. */
+ attno = 0;
+ values[attno++] = ObjectIdGetDatum(RelationGetRelid(rel));
+
+ values[attno++] =
+ CStringGetTextDatum(get_namespace_name(RelationGetNamespace(rel)));
+
+ values[attno++] = CStringGetTextDatum(RelationGetRelationName(rel));
+
+ values[attno++] = CStringGetTextDatum(ConflictTypeNames[conflict_type]);
+
+ if (TransactionIdIsValid(remote_xid))
+ values[attno++] = TransactionIdGetDatum(remote_xid);
+ else
+ nulls[attno++] = true;
+
+ values[attno++] = LSNGetDatum(remote_final_lsn);
+
+ if (remote_commit_ts > 0)
+ values[attno++] = TimestampTzGetDatum(remote_commit_ts);
+ else
+ nulls[attno++] = true;
+
+ if (replorigin_session_origin != InvalidRepOriginId)
+ replorigin_by_oid(replorigin_session_origin, true, &remote_origin);
+
+ if (remote_origin != NULL)
+ values[attno++] = CStringGetTextDatum(remote_origin);
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(searchslot))
+ {
+ Oid replica_index = GetRelationIdentityOrPK(rel);
+
+ /*
+ * If the table has a valid replica identity index, build the index
+ * json datum from key value. Otherwise, construct it from the complete
+ * tuple in REPLICA IDENTITY FULL cases.
+ */
+ if (OidIsValid(replica_index))
+ values[attno++] = tuple_table_slot_to_indextup_json(estate, rel,
+ replica_index,
+ searchslot);
+ else
+ values[attno++] = tuple_table_slot_to_json_datum(searchslot);
+ }
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(remoteslot))
+ values[attno++] = tuple_table_slot_to_json_datum(remoteslot);
+ else
+ nulls[attno++] = true;
+
+ values[attno] = build_local_conflicts_json_array(estate, rel,
+ conflict_type,
+ conflicttuples);
+
+ Assert(attno + 1 == MAX_CONFLICT_ATTR_NUM);
+
+ oldctx = MemoryContextSwitchTo(ApplyContext);
+ MyLogicalRepWorker->conflict_log_tuple =
+ heap_form_tuple(RelationGetDescr(conflictlogrel), values, nulls);
+ MemoryContextSwitchTo(oldctx);
}
diff --git a/src/backend/replication/logical/launcher.c b/src/backend/replication/logical/launcher.c
index 3ed86480be2..2dda5a44218 100644
--- a/src/backend/replication/logical/launcher.c
+++ b/src/backend/replication/logical/launcher.c
@@ -477,6 +477,7 @@ retry:
worker->oldest_nonremovable_xid = retain_dead_tuples
? MyReplicationSlot->data.xmin
: InvalidTransactionId;
+ worker->conflict_log_tuple = NULL;
worker->last_lsn = InvalidXLogRecPtr;
TIMESTAMP_NOBEGIN(worker->last_send_time);
TIMESTAMP_NOBEGIN(worker->last_recv_time);
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index ad281e7069b..d4be1122603 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -482,7 +482,9 @@ static bool MySubscriptionValid = false;
static List *on_commit_wakeup_workers_subids = NIL;
bool in_remote_transaction = false;
-static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+TransactionId remote_xid = InvalidTransactionId;
+TimestampTz remote_commit_ts = 0;
/* fields valid only when processing streamed transaction */
static bool in_streamed_transaction = false;
@@ -1219,6 +1221,8 @@ apply_handle_begin(StringInfo s)
set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
remote_final_lsn = begin_data.final_lsn;
+ remote_commit_ts = begin_data.committime;
+ remote_xid = begin_data.xid;
maybe_start_skipping_changes(begin_data.final_lsn);
@@ -1745,6 +1749,10 @@ apply_handle_stream_start(StringInfo s)
/* extract XID of the top-level transaction */
stream_xid = logicalrep_read_stream_start(s, &first_segment);
+ remote_xid = stream_xid;
+ remote_final_lsn = InvalidXLogRecPtr;
+ remote_commit_ts = 0;
+
if (!TransactionIdIsValid(stream_xid))
ereport(ERROR,
(errcode(ERRCODE_PROTOCOL_VIOLATION),
@@ -5609,6 +5617,27 @@ start_apply(XLogRecPtr origin_startpos)
pgstat_report_subscription_error(MySubscription->oid,
MyLogicalRepWorker->type);
+ /*
+ * Insert any pending conflict log tuple under a new transaction.
+ */
+ if (MyLogicalRepWorker->conflict_log_tuple != NULL)
+ {
+ Relation conflictlogrel;
+ ConflictLogDest dest;
+
+ StartTransactionCommand();
+ PushActiveSnapshot(GetTransactionSnapshot());
+
+ /* Open conflict log table and insert the tuple. */
+ conflictlogrel = GetConflictLogDestAndTable(&dest);
+ Assert((dest & CONFLICT_LOG_DEST_TABLE) != 0);
+ InsertConflictLogTuple(conflictlogrel);
+ table_close(conflictlogrel, RowExclusiveLock);
+
+ PopActiveSnapshot();
+ CommitTransactionCommand();
+ }
+
PG_RE_THROW();
}
}
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index 29da63dd127..76814ef5fd6 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -144,6 +144,8 @@ extern void ReportApplyConflict(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *remoteslot,
List *conflicttuples);
extern void InitConflictIndexes(ResultRelInfo *relInfo);
-extern Relation GetConflictLogTableInfo(ConflictLogDest *log_dest);
+extern Relation GetConflictLogDestAndTable(ConflictLogDest *log_dest);
+extern void InsertConflictLogTuple(Relation conflictlogrel);
+extern bool ValidateConflictLogTable(Relation rel);
extern void InsertConflictLogTuple(Relation conflictlogrel);
#endif
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index c1285fdd1bc..5bedfc5450f 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -101,6 +101,9 @@ typedef struct LogicalRepWorker
*/
TransactionId oldest_nonremovable_xid;
+ /* A conflict log tuple that is prepared but not yet inserted. */
+ HeapTuple conflict_log_tuple;
+
/* Stats. */
XLogRecPtr last_lsn;
TimestampTz last_send_time;
@@ -256,6 +259,10 @@ extern PGDLLIMPORT bool InitializingApplyWorker;
extern PGDLLIMPORT List *table_states_not_ready;
+extern XLogRecPtr remote_final_lsn;
+extern TimestampTz remote_commit_ts;
+extern TransactionId remote_xid;
+
extern void logicalrep_worker_attach(int slot);
extern LogicalRepWorker *logicalrep_worker_find(LogicalRepWorkerType wtype,
Oid subid, Oid relid,
diff --git a/src/test/subscription/t/035_conflicts.pl b/src/test/subscription/t/035_conflicts.pl
index ddc75e23fb0..d540e7fcb65 100644
--- a/src/test/subscription/t/035_conflicts.pl
+++ b/src/test/subscription/t/035_conflicts.pl
@@ -50,7 +50,7 @@ $node_subscriber->safe_psql(
'postgres',
"CREATE SUBSCRIPTION sub_tab
CONNECTION '$publisher_connstr application_name=$appname'
- PUBLICATION pub_tab;");
+ PUBLICATION pub_tab WITH (conflict_log_destination=all)");
# Wait for initial table sync to finish
$node_subscriber->wait_for_subscription_sync($node_publisher, $appname);
@@ -86,10 +86,38 @@ $node_subscriber->wait_for_log(
.*Key \(c\)=\(4\); existing local row \(4, 4, 4\); remote row \(2, 3, 4\)./,
$log_offset);
+my $subid = $node_subscriber->safe_psql('postgres',
+ "SELECT oid FROM pg_subscription WHERE subname = 'sub_tab';");
+my $conflict_table = "pg_conflict.pg_conflict_$subid";
+
+# Wait for the conflict to be logged
+my $log_check = $node_subscriber->poll_query_until(
+ 'postgres',
+ "SELECT count(*) > 0 FROM $conflict_table;"
+);
+
+my $conflict_check = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM $conflict_table WHERE conflict_type = 'multiple_unique_conflicts';");
+is($conflict_check, 1, 'Verified multiple_unique_conflicts logged into internal table');
+
+my $json_query = qq[
+ SELECT string_agg((unnested.j::json)->'key'->>'a', ',')
+ FROM (
+ SELECT unnest(local_conflicts) AS j
+ FROM $conflict_table
+ ) AS unnested;
+];
+
+my $all_keys = $node_subscriber->safe_psql('postgres', $json_query);
+
+# Verify that '2' is present in the resulting string
+like($all_keys, qr/2/, 'Verified that key 2 exists in the local_conflicts log');
+
pass('multiple_unique_conflicts detected during insert');
# Truncate table to get rid of the error
$node_subscriber->safe_psql('postgres', "TRUNCATE conf_tab;");
+$node_subscriber->safe_psql('postgres', "DELETE FROM $conflict_table");
##################################################
# Test multiple_unique_conflicts due to UPDATE
@@ -118,6 +146,29 @@ $node_subscriber->wait_for_log(
.*Key \(c\)=\(8\); existing local row \(8, 8, 8\); remote row \(6, 7, 8\)./,
$log_offset);
+# Wait for the conflict to be logged
+$log_check = $node_subscriber->poll_query_until(
+ 'postgres',
+ "SELECT count(*) > 0 FROM $conflict_table;"
+);
+
+$conflict_check = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM $conflict_table WHERE conflict_type = 'multiple_unique_conflicts';");
+is($conflict_check, 1, 'Verified multiple_unique_conflicts logged into internal table');
+
+$json_query = qq[
+ SELECT string_agg((unnested.j::json)->'key'->>'a', ',')
+ FROM (
+ SELECT unnest(local_conflicts) AS j
+ FROM $conflict_table
+ ) AS unnested;
+];
+
+$all_keys = $node_subscriber->safe_psql('postgres', $json_query);
+
+# Verify that '6' is present in the resulting string
+like($all_keys, qr/6/, 'Verified that key 6 exists in the local_conflicts log');
+
pass('multiple_unique_conflicts detected during update');
# Truncate table to get rid of the error
--
2.49.0
[application/octet-stream] v20-0001-Add-configurable-conflict-log-table-for-Logical-.patch (106.7K, ../../CAFiTN-vj8NTm9w_L2XdhxJCub_RZw__YVUgfXa1B1kJzJctRNw@mail.gmail.com/3-v20-0001-Add-configurable-conflict-log-table-for-Logical-.patch)
download | inline diff:
From 4d38d5d1f5be937f120f914e0e5a9fdcaa9f6636 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 17 Dec 2025 11:53:47 +0530
Subject: [PATCH v20 1/3] Add configurable conflict log table for Logical
Replication
This patch adds a feature to provide a structured, queryable record of all
logical replication conflicts. The current approach of logging conflicts as
plain text in the server logs makes it difficult to query, analyze, and
use for external monitoring and automation.
This patch addresses these limitations by introducing a configurable
conflict_log_destination=('log'/'table'/'all') option in the CREATE SUBSCRIPTION
command.
If the user chooses to enable logging to a table (by selecting 'table' or 'all'),
an internal logging table named conflict_log_table_<subid> is automatically
created within a dedicated, system-managed namespace named pg_conflict. This table
is linked to the subscription via an internal dependency, ensuring it is
automatically dropped when the subscription is removed.
The conflict details, including the original and remote tuples, are stored in JSON
columns, providing a flexible format to accommodate different table schemas.
The log table captures essential attributes such as local and remote transaction IDs,
LSNs, commit timestamps, and conflict type, providing a complete record for post-mortem
analysis.
This feature will make logical replication conflicts easier to monitor and manage,
significantly improving the overall resilience and operability of replication setups.
The conflict log tables will not be included in a publication, even if the publication
is configured to include ALL TABLES or ALL TABLES IN SCHEMA.
---
src/backend/catalog/catalog.c | 27 +-
src/backend/catalog/heap.c | 3 +-
src/backend/catalog/namespace.c | 8 +-
src/backend/catalog/pg_publication.c | 24 +-
src/backend/catalog/pg_subscription.c | 7 +
src/backend/commands/subscriptioncmds.c | 240 +++++++++++-
src/bin/psql/describe.c | 21 +-
src/bin/psql/tab-complete.in.c | 8 +-
src/include/catalog/catalog.h | 2 +
src/include/catalog/pg_namespace.dat | 3 +
src/include/catalog/pg_subscription.h | 11 +
src/include/commands/subscriptioncmds.h | 3 +
src/include/replication/conflict.h | 57 +++
src/test/regress/expected/subscription.out | 407 ++++++++++++++++-----
src/test/regress/sql/subscription.sql | 189 ++++++++++
src/tools/pgindent/typedefs.list | 2 +
16 files changed, 906 insertions(+), 106 deletions(-)
diff --git a/src/backend/catalog/catalog.c b/src/backend/catalog/catalog.c
index 7be49032934..d438dc682ec 100644
--- a/src/backend/catalog/catalog.c
+++ b/src/backend/catalog/catalog.c
@@ -86,7 +86,8 @@ bool
IsSystemClass(Oid relid, Form_pg_class reltuple)
{
/* IsCatalogRelationOid is a bit faster, so test that first */
- return (IsCatalogRelationOid(relid) || IsToastClass(reltuple));
+ return (IsCatalogRelationOid(relid) || IsToastClass(reltuple)
+ || IsConflictClass(reltuple));
}
/*
@@ -230,6 +231,18 @@ IsToastClass(Form_pg_class reltuple)
return IsToastNamespace(relnamespace);
}
+/*
+ * IsConflictClass - Check if the given pg_class tuple belongs to the conflict
+ * namespace.
+ */
+bool
+IsConflictClass(Form_pg_class reltuple)
+{
+ Oid relnamespace = reltuple->relnamespace;
+
+ return IsConflictNamespace(relnamespace);
+}
+
/*
* IsCatalogNamespace
* True iff namespace is pg_catalog.
@@ -264,6 +277,18 @@ IsToastNamespace(Oid namespaceId)
isTempToastNamespace(namespaceId);
}
+/*
+ * IsConflictNamespace
+ * True iff namespace is pg_conflict.
+ *
+ * Does not perform any catalog accesses.
+ */
+bool
+IsConflictNamespace(Oid namespaceId)
+{
+ return namespaceId == PG_CONFLICT_NAMESPACE;
+}
+
/*
* IsReservedName
diff --git a/src/backend/catalog/heap.c b/src/backend/catalog/heap.c
index 606434823cf..10dadf378a4 100644
--- a/src/backend/catalog/heap.c
+++ b/src/backend/catalog/heap.c
@@ -314,7 +314,8 @@ heap_create(const char *relname,
*/
if (!allow_system_table_mods &&
((IsCatalogNamespace(relnamespace) && relkind != RELKIND_INDEX) ||
- IsToastNamespace(relnamespace)) &&
+ IsToastNamespace(relnamespace) ||
+ IsConflictNamespace(relnamespace)) &&
IsNormalProcessingMode())
ereport(ERROR,
(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
diff --git a/src/backend/catalog/namespace.c b/src/backend/catalog/namespace.c
index c3b79a2ba48..400292fd06b 100644
--- a/src/backend/catalog/namespace.c
+++ b/src/backend/catalog/namespace.c
@@ -3523,7 +3523,7 @@ LookupCreationNamespace(const char *nspname)
*
* We complain if either the old or new namespaces is a temporary schema
* (or temporary toast schema), or if either the old or new namespaces is the
- * TOAST schema.
+ * TOAST schema or the CONFLICT schema.
*/
void
CheckSetNamespace(Oid oldNspOid, Oid nspOid)
@@ -3539,6 +3539,12 @@ CheckSetNamespace(Oid oldNspOid, Oid nspOid)
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("cannot move objects into or out of TOAST schema")));
+
+ /* similarly for CONFLICT schema */
+ if (nspOid == PG_CONFLICT_NAMESPACE || oldNspOid == PG_CONFLICT_NAMESPACE)
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot move objects into or out of CONFLICT schema")));
}
/*
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index 9a4791c573e..2dc23bff518 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -31,6 +31,7 @@
#include "catalog/pg_publication_rel.h"
#include "catalog/pg_type.h"
#include "commands/publicationcmds.h"
+#include "commands/subscriptioncmds.h"
#include "funcapi.h"
#include "utils/array.h"
#include "utils/builtins.h"
@@ -85,6 +86,15 @@ check_publication_add_relation(Relation targetrel)
errmsg("cannot add relation \"%s\" to publication",
RelationGetRelationName(targetrel)),
errdetail("This operation is not supported for unlogged tables.")));
+
+ /* Can't be conflict log table */
+ if (IsConflictNamespace(RelationGetNamespace(targetrel)))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot add relation \"%s.%s\" to publication",
+ get_namespace_name(RelationGetNamespace(targetrel)),
+ RelationGetRelationName(targetrel)),
+ errdetail("This operation is not supported for conflict log tables.")));
}
/*
@@ -95,7 +105,8 @@ static void
check_publication_add_schema(Oid schemaid)
{
/* Can't be system namespace */
- if (IsCatalogNamespace(schemaid) || IsToastNamespace(schemaid))
+ if (IsCatalogNamespace(schemaid) || IsToastNamespace(schemaid) ||
+ IsConflictNamespace(schemaid))
ereport(ERROR,
(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
errmsg("cannot add schema \"%s\" to publication",
@@ -139,12 +150,20 @@ is_publishable_class(Oid relid, Form_pg_class reltuple)
reltuple->relkind == RELKIND_PARTITIONED_TABLE ||
reltuple->relkind == RELKIND_SEQUENCE) &&
!IsCatalogRelationOid(relid) &&
+ !IsConflictClass(reltuple) &&
reltuple->relpersistence == RELPERSISTENCE_PERMANENT &&
relid >= FirstNormalObjectId;
}
/*
* Another variant of is_publishable_class(), taking a Relation.
+ *
+ * Note: Conflict log tables are not publishable. However, we intentionally
+ * skip this check here because this function is called for every change and
+ * performing this check during every change publication is costly. To ensure
+ * unpublishable entries are ignored without incurring performance overhead,
+ * tuples inserted into the conflict log table uses the HEAP_INSERT_NO_LOGICAL
+ * flag. This allows the decoding layer to bypass these entries automatically.
*/
bool
is_publishable_relation(Relation rel)
@@ -169,6 +188,8 @@ pg_relation_is_publishable(PG_FUNCTION_ARGS)
tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
if (!HeapTupleIsValid(tuple))
PG_RETURN_NULL();
+
+ /* Subscription conflict log tables are not published */
result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple));
ReleaseSysCache(tuple);
PG_RETURN_BOOL(result);
@@ -890,6 +911,7 @@ GetAllPublicationRelations(char relkind, bool pubviaroot)
Form_pg_class relForm = (Form_pg_class) GETSTRUCT(tuple);
Oid relid = relForm->oid;
+ /* Subscription conflict log tables are not published */
if (is_publishable_class(relid, relForm) &&
!(relForm->relispartition && pubviaroot))
result = lappend_oid(result, relid);
diff --git a/src/backend/catalog/pg_subscription.c b/src/backend/catalog/pg_subscription.c
index 2b103245290..285a598497d 100644
--- a/src/backend/catalog/pg_subscription.c
+++ b/src/backend/catalog/pg_subscription.c
@@ -106,6 +106,7 @@ GetSubscription(Oid subid, bool missing_ok)
sub->retaindeadtuples = subform->subretaindeadtuples;
sub->maxretention = subform->submaxretention;
sub->retentionactive = subform->subretentionactive;
+ sub->conflictlogrelid = subform->subconflictlogrelid;
/* Get conninfo */
datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
@@ -141,6 +142,12 @@ GetSubscription(Oid subid, bool missing_ok)
Anum_pg_subscription_suborigin);
sub->origin = TextDatumGetCString(datum);
+ /* Get conflict log destination */
+ datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
+ tup,
+ Anum_pg_subscription_subconflictlogdest);
+ sub->conflictlogdest = TextDatumGetCString(datum);
+
/* Is the subscription owner a superuser? */
sub->ownersuperuser = superuser_arg(sub->owner);
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index d6674f20fc2..f464606ba8a 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -15,25 +15,31 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/htup_details.h"
#include "access/table.h"
#include "access/twophase.h"
#include "access/xact.h"
#include "catalog/catalog.h"
#include "catalog/dependency.h"
+#include "catalog/heap.h"
#include "catalog/indexing.h"
#include "catalog/namespace.h"
#include "catalog/objectaccess.h"
#include "catalog/objectaddress.h"
+#include "catalog/pg_am_d.h"
#include "catalog/pg_authid_d.h"
#include "catalog/pg_database_d.h"
+#include "catalog/pg_namespace.h"
#include "catalog/pg_subscription.h"
#include "catalog/pg_subscription_rel.h"
#include "catalog/pg_type.h"
+#include "commands/comment.h"
#include "commands/defrem.h"
#include "commands/event_trigger.h"
#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "miscadmin.h"
#include "nodes/makefuncs.h"
#include "pgstat.h"
@@ -51,6 +57,7 @@
#include "utils/lsyscache.h"
#include "utils/memutils.h"
#include "utils/pg_lsn.h"
+#include "utils/regproc.h"
#include "utils/syscache.h"
/*
@@ -75,6 +82,7 @@
#define SUBOPT_MAX_RETENTION_DURATION 0x00008000
#define SUBOPT_LSN 0x00010000
#define SUBOPT_ORIGIN 0x00020000
+#define SUBOPT_CONFLICT_LOG_DEST 0x00040000
/* check if the 'val' has 'bits' set */
#define IsSet(val, bits) (((val) & (bits)) == (bits))
@@ -103,6 +111,7 @@ typedef struct SubOpts
bool retaindeadtuples;
int32 maxretention;
char *origin;
+ ConflictLogDest logdest;
XLogRecPtr lsn;
} SubOpts;
@@ -135,7 +144,7 @@ static List *merge_publications(List *oldpublist, List *newpublist, bool addpub,
static void ReportSlotConnectionError(List *rstates, Oid subid, char *slotname, char *err);
static void CheckAlterSubOption(Subscription *sub, const char *option,
bool slot_needs_update, bool isTopLevel);
-
+static Oid create_conflict_log_table(Oid subid, char *subname);
/*
* Common option parsing function for CREATE and ALTER SUBSCRIPTION commands.
@@ -191,6 +200,8 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->maxretention = 0;
if (IsSet(supported_opts, SUBOPT_ORIGIN))
opts->origin = pstrdup(LOGICALREP_ORIGIN_ANY);
+ if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DEST))
+ opts->logdest = CONFLICT_LOG_DEST_LOG;
/* Parse options */
foreach(lc, stmt_options)
@@ -402,6 +413,18 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->specified_opts |= SUBOPT_LSN;
opts->lsn = lsn;
}
+ else if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DEST) &&
+ strcmp(defel->defname, "conflict_log_destination") == 0)
+ {
+ char *val;
+
+ if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_LOG_DEST))
+ errorConflictingDefElem(defel, pstate);
+
+ val = defGetString(defel);
+ opts->logdest = GetLogDestination(val);
+ opts->specified_opts |= SUBOPT_CONFLICT_LOG_DEST;
+ }
else
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
@@ -599,6 +622,7 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bits32 supported_opts;
SubOpts opts = {0};
AclResult aclresult;
+ Oid logrelid = InvalidOid;
/*
* Parse and check options.
@@ -612,7 +636,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
SUBOPT_DISABLE_ON_ERR | SUBOPT_PASSWORD_REQUIRED |
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
- SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN);
+ SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_DEST);
parse_subscription_options(pstate, stmt->options, supported_opts, &opts);
/*
@@ -747,6 +772,18 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
values[Anum_pg_subscription_suborigin - 1] =
CStringGetTextDatum(opts.origin);
+ /* Always set the destination, default will be 'log'. */
+ values[Anum_pg_subscription_subconflictlogdest - 1] =
+ CStringGetTextDatum(ConflictLogDestNames[opts.logdest]);
+
+ /* If logging to a table is required, physically create the table. */
+ if (IsSet(opts.logdest, CONFLICT_LOG_DEST_TABLE))
+ logrelid = create_conflict_log_table(subid, stmt->subname);
+
+ /* Store table OID in the catalog. */
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(logrelid);
+
tup = heap_form_tuple(RelationGetDescr(rel), values, nulls);
/* Insert tuple into catalog. */
@@ -1410,7 +1447,8 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
SUBOPT_MAX_RETENTION_DURATION |
- SUBOPT_ORIGIN);
+ SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_DEST);
parse_subscription_options(pstate, stmt->options,
supported_opts, &opts);
@@ -1665,6 +1703,63 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
origin = opts.origin;
}
+ if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_LOG_DEST))
+ {
+ ConflictLogDest old_dest =
+ GetLogDestination(sub->conflictlogdest);
+
+ if (opts.logdest != old_dest)
+ {
+ bool want_table =
+ IsSet(opts.logdest, CONFLICT_LOG_DEST_TABLE);
+ bool has_oldtable =
+ IsSet(old_dest, CONFLICT_LOG_DEST_TABLE);
+
+ values[Anum_pg_subscription_subconflictlogdest - 1] =
+ CStringGetTextDatum(ConflictLogDestNames[opts.logdest]);
+ replaces[Anum_pg_subscription_subconflictlogdest - 1] = true;
+
+ if (want_table && !has_oldtable)
+ {
+ Oid relid;
+
+ relid = create_conflict_log_table(subid, sub->name);
+
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(relid);
+ replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
+ true;
+ }
+ else if (!want_table && has_oldtable)
+ {
+ ObjectAddress object;
+
+ /*
+ * Conflict log tables are recorded as internal
+ * dependencies of the subscription. Drop the
+ * table if it is not required anymore to avoid
+ * stale or orphaned relations.
+ *
+ * XXX: At present, only conflict log tables are
+ * managed this way. In future if we introduce
+ * additional internal dependencies, we may need
+ * a targeted deletion to avoid deletion of any
+ * other objects.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId,
+ subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(InvalidOid);
+ replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
+ true;
+ }
+ }
+ }
+
update_tuple = true;
break;
}
@@ -2027,6 +2122,7 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
Form_pg_subscription form;
List *rstates;
bool must_use_password;
+ ObjectAddress object;
/*
* The launcher may concurrently start a new worker for this subscription.
@@ -2184,6 +2280,19 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
/* Clean up dependencies */
deleteSharedDependencyRecordsFor(SubscriptionRelationId, subid, 0);
+ /*
+ * Conflict log tables are recorded as internal dependencies of the
+ * subscription. We must drop the dependent objects before the
+ * subscription itself is removed. By using
+ * PERFORM_DELETION_SKIP_ORIGINAL, we ensure that only the conflict log
+ * table is reaped while the subscription remains for the final deletion
+ * step.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId, subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
/* Remove any associated relation synchronization states. */
RemoveSubscriptionRel(subid, InvalidOid);
@@ -3190,3 +3299,128 @@ defGetStreamingMode(DefElem *def)
def->defname)));
return LOGICALREP_STREAM_OFF; /* keep compiler quiet */
}
+
+/*
+ * Builds the TupleDesc for the conflict log table.
+ */
+static TupleDesc
+create_conflict_log_table_tupdesc(void)
+{
+ TupleDesc tupdesc;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_CONFLICT_ATTR_NUM);
+
+ for (int i = 0; i < MAX_CONFLICT_ATTR_NUM; i++)
+ {
+ Oid type_oid = ConflictLogSchema[i].atttypid;
+
+ /*
+ * Special handling for the JSON array type for proper
+ * TupleDescInitEntry call.
+ */
+ if (type_oid == JSONARRAYOID)
+ type_oid = get_array_type(JSONOID);
+
+ TupleDescInitEntry(tupdesc, i + 1,
+ ConflictLogSchema[i].attname,
+ type_oid,
+ -1, 0);
+ }
+
+ return BlessTupleDesc(tupdesc);
+}
+
+/*
+ * Create a structured conflict log table for a subscription.
+ *
+ * The table is created within the system-managed 'pg_conflict' namespace.
+ * The table name is generated automatically using the subscription's OID
+ * (e.g., "pg_conflict_<subid>") to ensure uniqueness within the cluster and
+ * to avoid collisions during subscription renames.
+ */
+static Oid
+create_conflict_log_table(Oid subid, char *subname)
+{
+ TupleDesc tupdesc;
+ Oid relid;
+ ObjectAddress myself;
+ ObjectAddress subaddr;
+ char relname[NAMEDATALEN];
+
+ snprintf(relname, NAMEDATALEN, "pg_conflict_%u", subid);
+
+ /* There can not be an existing table with the same name. */
+ Assert(!OidIsValid(get_relname_relid(relname, PG_CONFLICT_NAMESPACE)));
+
+ /* Build the tuple descriptor for the new table. */
+ tupdesc = create_conflict_log_table_tupdesc();
+
+ /* Create conflict log table. */
+ relid = heap_create_with_catalog(relname,
+ PG_CONFLICT_NAMESPACE,
+ 0, /* tablespace */
+ InvalidOid, /* relid */
+ InvalidOid, /* reltypeid */
+ InvalidOid, /* reloftypeid */
+ GetUserId(),
+ HEAP_TABLE_AM_OID,
+ tupdesc,
+ NIL,
+ RELKIND_RELATION,
+ RELPERSISTENCE_PERMANENT,
+ false, /* shared_relation */
+ false, /* mapped_relation */
+ ONCOMMIT_NOOP,
+ (Datum) 0, /* reloptions */
+ false, /* use_user_acl */
+ true, /* allow_system_table_mods */
+ true, /* is_internal */
+ InvalidOid, /* relrewrite */
+ NULL); /* typaddress */
+
+ /*
+ * Establish an internal dependency between the conflict log table and
+ * the subscription.
+ *
+ * We use DEPENDENCY_INTERNAL to signify that the table's lifecycle is
+ * strictly tied to the subscription, similar to how a TOAST table relates
+ * to its main table or a sequence relates to an identity column.
+ *
+ * This ensures the conflict log table is automatically reaped during a
+ * DROP SUBSCRIPTION via performDeletion().
+ */
+ ObjectAddressSet(myself, RelationRelationId, relid);
+ ObjectAddressSet(subaddr, SubscriptionRelationId, subid);
+ recordDependencyOn(&myself, &subaddr, DEPENDENCY_INTERNAL);
+
+ /* Release tuple descriptor memory. */
+ FreeTupleDesc(tupdesc);
+
+ return relid;
+}
+
+/*
+ * GetLogDestination
+ *
+ * Convert string to enum by comparing against standardized labels.
+ */
+ConflictLogDest
+GetLogDestination(const char *dest)
+{
+ /* Empty string or NULL defaults to LOG. */
+ if (dest == NULL || dest[0] == '\0' || pg_strcasecmp(dest, "log") == 0)
+ return CONFLICT_LOG_DEST_LOG;
+
+ if (pg_strcasecmp(dest, "table") == 0)
+ return CONFLICT_LOG_DEST_TABLE;
+
+ if (pg_strcasecmp(dest, "all") == 0)
+ return CONFLICT_LOG_DEST_ALL;
+
+ /* Unrecognized string. */
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("unrecognized conflict_log_destination value: \"%s\"", dest),
+ errhint("Valid values are \"log\", \"table\", and \"all\".")));
+}
+
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index 3584c4e1428..20f08e548ba 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -6806,7 +6806,7 @@ describeSubscriptions(const char *pattern, bool verbose)
printQueryOpt myopt = pset.popt;
static const bool translate_columns[] = {false, false, false, false,
false, false, false, false, false, false, false, false, false, false,
- false, false, false, false};
+ false, false, false, false, false};
if (pset.sversion < 100000)
{
@@ -6900,15 +6900,22 @@ describeSubscriptions(const char *pattern, bool verbose)
appendPQExpBuffer(&buf,
", subskiplsn AS \"%s\"\n",
gettext_noop("Skip LSN"));
+
+ /* Conflict log destination is supported in v19 and higher */
+ if (pset.sversion >= 190000)
+ {
+ appendPQExpBuffer(&buf,
+ ", subconflictlogdest AS \"%s\"\n",
+ gettext_noop("Conflict log destination"));
+ }
}
/* Only display subscriptions in current database. */
- appendPQExpBufferStr(&buf,
- "FROM pg_catalog.pg_subscription\n"
- "WHERE subdbid = (SELECT oid\n"
- " FROM pg_catalog.pg_database\n"
- " WHERE datname = pg_catalog.current_database())");
-
+ appendPQExpBuffer(&buf,
+ "FROM pg_catalog.pg_subscription "
+ "WHERE subdbid = (SELECT oid\n"
+ " FROM pg_catalog.pg_database\n"
+ " WHERE datname = pg_catalog.current_database())");
if (!validateSQLNamePattern(&buf, pattern, true, false,
NULL, "subname", NULL,
NULL,
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 8b91bc00062..12eee8a0d43 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -2344,8 +2344,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("(", "PUBLICATION");
/* ALTER SUBSCRIPTION <name> SET ( */
else if (Matches("ALTER", "SUBSCRIPTION", MatchAny, MatchAnyN, "SET", "("))
- COMPLETE_WITH("binary", "disable_on_error", "failover",
- "max_retention_duration", "origin",
+ COMPLETE_WITH("binary", "conflict_log_destination", "disable_on_error",
+ "failover", "max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
"synchronous_commit", "two_phase");
@@ -3860,8 +3860,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("WITH (");
/* Complete "CREATE SUBSCRIPTION <name> ... WITH ( <opt>" */
else if (Matches("CREATE", "SUBSCRIPTION", MatchAnyN, "WITH", "("))
- COMPLETE_WITH("binary", "connect", "copy_data", "create_slot",
- "disable_on_error", "enabled", "failover",
+ COMPLETE_WITH("binary", "conflict_log_destination", "connect", "copy_data",
+ "create_slot", "disable_on_error", "enabled", "failover",
"max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
diff --git a/src/include/catalog/catalog.h b/src/include/catalog/catalog.h
index a9d6e8ea986..8193229f2e2 100644
--- a/src/include/catalog/catalog.h
+++ b/src/include/catalog/catalog.h
@@ -25,6 +25,7 @@ extern bool IsInplaceUpdateRelation(Relation relation);
extern bool IsSystemClass(Oid relid, Form_pg_class reltuple);
extern bool IsToastClass(Form_pg_class reltuple);
+extern bool IsConflictClass(Form_pg_class reltuple);
extern bool IsCatalogRelationOid(Oid relid);
extern bool IsCatalogTextUniqueIndexOid(Oid relid);
@@ -32,6 +33,7 @@ extern bool IsInplaceUpdateOid(Oid relid);
extern bool IsCatalogNamespace(Oid namespaceId);
extern bool IsToastNamespace(Oid namespaceId);
+extern bool IsConflictNamespace(Oid namespaceId);
extern bool IsReservedName(const char *name);
diff --git a/src/include/catalog/pg_namespace.dat b/src/include/catalog/pg_namespace.dat
index 3075e142c73..b45cb9383a8 100644
--- a/src/include/catalog/pg_namespace.dat
+++ b/src/include/catalog/pg_namespace.dat
@@ -18,6 +18,9 @@
{ oid => '99', oid_symbol => 'PG_TOAST_NAMESPACE',
descr => 'reserved schema for TOAST tables',
nspname => 'pg_toast', nspacl => '_null_' },
+{ oid => '1382', oid_symbol => 'PG_CONFLICT_NAMESPACE',
+ descr => 'reserved schema for subscription-specific conflict log tables',
+ nspname => 'pg_conflict', nspacl => '_null_' },
# update dumpNamespace() if changing this descr
{ oid => '2200', oid_symbol => 'PG_PUBLIC_NAMESPACE',
descr => 'standard public schema',
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index f3571d2bfcf..4aa29ea15d4 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -90,6 +90,7 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
* exceeded max_retention_duration, when
* defined */
+ Oid subconflictlogrelid; /* Relid of the conflict log table. */
#ifdef CATALOG_VARLEN /* variable-length fields start here */
/* Connection string to the publisher */
text subconninfo BKI_FORCE_NOT_NULL;
@@ -103,6 +104,14 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
/* List of publications subscribed to */
text subpublications[1] BKI_FORCE_NOT_NULL;
+ /*
+ * Strategy for logging replication conflicts:
+ * 'log' - server log only,
+ * 'table' - internal table only,
+ * 'all' - both log and table.
+ */
+ text subconflictlogdest;
+
/* Only publish data originating from the specified origin */
text suborigin BKI_DEFAULT(LOGICALREP_ORIGIN_ANY);
#endif
@@ -152,12 +161,14 @@ typedef struct Subscription
* and the retention duration has not
* exceeded max_retention_duration, when
* defined */
+ Oid conflictlogrelid; /* conflict log table Oid */
char *conninfo; /* Connection string to the publisher */
char *slotname; /* Name of the replication slot */
char *synccommit; /* Synchronous commit setting for worker */
List *publications; /* List of publication names to subscribe to */
char *origin; /* Only publish data originating from the
* specified origin */
+ char *conflictlogdest; /* Conflict log destination */
} Subscription;
#ifdef EXPOSE_TO_CLIENT_CODE
diff --git a/src/include/commands/subscriptioncmds.h b/src/include/commands/subscriptioncmds.h
index 63504232a14..a895127f8fe 100644
--- a/src/include/commands/subscriptioncmds.h
+++ b/src/include/commands/subscriptioncmds.h
@@ -17,6 +17,7 @@
#include "catalog/objectaddress.h"
#include "parser/parse_node.h"
+#include "replication/conflict.h"
extern ObjectAddress CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bool isTopLevel);
@@ -36,4 +37,6 @@ extern void CheckSubDeadTupleRetention(bool check_guc, bool sub_disabled,
bool retention_active,
bool max_retention_set);
+extern ConflictLogDest GetLogDestination(const char *dest);
+
#endif /* SUBSCRIPTIONCMDS_H */
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index d538274637f..29da63dd127 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -10,6 +10,7 @@
#define CONFLICT_H
#include "access/xlogdefs.h"
+#include "catalog/pg_type.h"
#include "nodes/pg_list.h"
#include "utils/timestamp.h"
@@ -79,6 +80,60 @@ typedef struct ConflictTupleInfo
* conflicting local row occurred */
} ConflictTupleInfo;
+/*
+ * Conflict log destination types.
+ *
+ * These values are defined as bitmask flags to allow for multiple simultaneous
+ * logging destinations (e.g., logging to both system logs and a table).
+ * Internally, we use these for bitwise comparisons (IsSet), but the string
+ * representation is stored in pg_subscription.subconflictlogdest.
+ */
+typedef enum ConflictLogDest
+{
+ /* Log conflicts to the server logs */
+ CONFLICT_LOG_DEST_LOG = 1 << 0, /* 0x01 */
+
+ /* Log conflicts to an internally managed table */
+ CONFLICT_LOG_DEST_TABLE = 1 << 1, /* 0x02 */
+
+ /* Convenience flag for all supported destinations */
+ CONFLICT_LOG_DEST_ALL = (CONFLICT_LOG_DEST_LOG | CONFLICT_LOG_DEST_TABLE)
+} ConflictLogDest;
+
+/*
+ * Array mapping for converting internal enum to string.
+ */
+static const char *const ConflictLogDestNames[] = {
+ [CONFLICT_LOG_DEST_LOG] = "log",
+ [CONFLICT_LOG_DEST_TABLE] = "table",
+ [CONFLICT_LOG_DEST_ALL] = "all"
+};
+
+/* Structure to hold metadata for one column of the conflict log table */
+typedef struct ConflictLogColumnDef
+{
+ const char *attname; /* Column name */
+ Oid atttypid; /* Data type OID */
+} ConflictLogColumnDef;
+
+/* The single source of truth for the conflict log table schema */
+static const ConflictLogColumnDef ConflictLogSchema[] =
+{
+ { .attname = "relid", .atttypid = OIDOID },
+ { .attname = "schemaname", .atttypid = TEXTOID },
+ { .attname = "relname", .atttypid = TEXTOID },
+ { .attname = "conflict_type", .atttypid = TEXTOID },
+ { .attname = "remote_xid", .atttypid = XIDOID },
+ { .attname = "remote_commit_lsn",.atttypid = LSNOID },
+ { .attname = "remote_commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "remote_origin", .atttypid = TEXTOID },
+ { .attname = "replica_identity", .atttypid = JSONOID },
+ { .attname = "remote_tuple", .atttypid = JSONOID },
+ { .attname = "local_conflicts", .atttypid = JSONARRAYOID }
+};
+
+#define MAX_CONFLICT_ATTR_NUM lengthof(ConflictLogSchema)
+
extern bool GetTupleTransactionInfo(TupleTableSlot *localslot,
TransactionId *xmin,
RepOriginId *localorigin,
@@ -89,4 +144,6 @@ extern void ReportApplyConflict(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *remoteslot,
List *conflicttuples);
extern void InitConflictIndexes(ResultRelInfo *relInfo);
+extern Relation GetConflictLogTableInfo(ConflictLogDest *log_dest);
+extern void InsertConflictLogTuple(Relation conflictlogrel);
#endif
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index b3eccd8afe3..dbc12b1adbd 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -116,18 +116,18 @@ CREATE SUBSCRIPTION regress_testsub4 CONNECTION 'dbname=regress_doesnotexist' PU
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub4 SET (origin = any);
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub3;
@@ -145,10 +145,10 @@ ALTER SUBSCRIPTION regress_testsub CONNECTION 'foobar';
ERROR: invalid connection string syntax: missing "=" after "foobar" in connection info string
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET PUBLICATION testpub2, testpub3 WITH (refresh = false);
@@ -157,10 +157,10 @@ ALTER SUBSCRIPTION regress_testsub SET (slot_name = 'newname');
ALTER SUBSCRIPTION regress_testsub SET (password_required = false);
ALTER SUBSCRIPTION regress_testsub SET (run_as_owner = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (password_required = true);
@@ -176,10 +176,10 @@ ERROR: unrecognized subscription parameter: "create_slot"
-- ok
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/12345');
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345 | log
(1 row)
-- ok - with lsn = NONE
@@ -188,10 +188,10 @@ ALTER SUBSCRIPTION regress_testsub SKIP (lsn = NONE);
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/0');
ERROR: invalid WAL location (LSN): 0/0
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
BEGIN;
@@ -223,10 +223,10 @@ ALTER SUBSCRIPTION regress_testsub_foo SET (synchronous_commit = foobar);
ERROR: invalid value for parameter "synchronous_commit": "foobar"
HINT: Available values: local, remote_write, remote_apply, on, off.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
----------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+---------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
-- rename back to keep the rest simple
@@ -255,19 +255,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (binary = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -279,27 +279,27 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = parallel);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- fail - publication already exists
@@ -314,10 +314,10 @@ ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refr
ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refresh = false);
ERROR: publication "testpub1" is already in subscription "regress_testsub"
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- fail - publication used more than once
@@ -332,10 +332,10 @@ ERROR: publication "testpub3" is not in subscription "regress_testsub"
-- ok - delete publications
ALTER SUBSCRIPTION regress_testsub DROP PUBLICATION testpub1, testpub2 WITH (refresh = false);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -371,19 +371,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- we can alter streaming when two_phase enabled
ALTER SUBSCRIPTION regress_testsub SET (streaming = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -393,10 +393,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -409,18 +409,18 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (disable_on_error = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -433,10 +433,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -450,19 +450,19 @@ NOTICE: max_retention_duration is ineffective when retain_dead_tuples is disabl
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- ok
ALTER SUBSCRIPTION regress_testsub SET (max_retention_duration = 0);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -517,6 +517,237 @@ COMMIT;
-- ok, owning it is enough for this stuff
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG DESTINATION TESTS
+--
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+-- fail - unrecognized parameter value
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
+ERROR: unrecognized conflict_log_destination value: "invalid"
+HINT: Valid values are "log", "table", and "all".
+-- verify subconflictlogdest is 'log' and relid is 0 (InvalidOid) for default case
+CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
+ subname | subconflictlogdest | subconflictlogrelid
+------------------------------+--------------------+---------------------
+ regress_conflict_log_default | log | 0
+(1 row)
+
+-- verify empty string defaults to 'log'
+CREATE SUBSCRIPTION regress_conflict_empty_str CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = '');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
+ subname | subconflictlogdest | subconflictlogrelid
+----------------------------+--------------------+---------------------
+ regress_conflict_empty_str | log | 0
+(1 row)
+
+-- this should generate an internal table named pg_conflict_$subid$
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- check metadata in pg_subscription: destination should be 'table' and relid valid
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname | subconflictlogdest | has_relid
+------------------------+--------------------+-----------
+ regress_conflict_test1 | table | t
+(1 row)
+
+-- verify the physical table exists, its OID matches subconflictlogrelid,
+-- and it is located in the 'pg_conflict' namespace.
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+JOIN pg_namespace n ON c.relnamespace = n.oid
+WHERE s.subname = 'regress_conflict_test1'
+ AND c.oid = s.subconflictlogrelid
+ AND n.nspname = 'pg_conflict';
+ count
+-------
+ 1
+(1 row)
+
+-- check if the internal table has the correct schema (11 columns)
+SELECT count(*)
+FROM pg_attribute a
+JOIN pg_class c ON a.attrelid = c.oid
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0;
+ count
+-------
+ 11
+(1 row)
+
+--
+-- ALTER SUBSCRIPTION - conflict_log_destination state transitions
+--
+-- These tests verify the transition logic between different logging
+-- destinations, ensuring internal tables are created or dropped as expected.
+--
+-- transition from 'log' to 'all'
+-- a new internal conflict log table should be created
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
+-- verify metadata after ALTER (destination should be 'all')
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogdest | has_relid
+------------------------+--------------------+-----------
+ regress_conflict_test2 | all | t
+(1 row)
+
+-- transition from 'all' to 'table'
+-- should NOT drop the table, only change destination string
+SELECT subconflictlogrelid AS old_relid FROM pg_subscription WHERE subname = 'regress_conflict_test2' \gset
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'table');
+SELECT subconflictlogdest, subconflictlogrelid = :old_relid AS relid_unchanged
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subconflictlogdest | relid_unchanged
+--------------------+-----------------
+ table | t
+(1 row)
+
+-- transition from 'table' to 'log'
+-- should drop the table and clear relid
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log');
+SELECT subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subconflictlogdest | subconflictlogrelid
+--------------------+---------------------
+ log | 0
+(1 row)
+
+-- verify the physical table is gone
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test2';
+ count
+-------
+ 0
+(1 row)
+
+--
+-- PUBLICATION: Verify internal tables are not publishable
+--
+-- pg_relation_is_publishable should return false for internal conflict log
+-- tables to prevent them from being accidentally included in publications.
+--
+SELECT n.nspname, pg_relation_is_publishable(c.oid)
+FROM pg_class c
+JOIN pg_namespace n ON c.relnamespace = n.oid
+JOIN pg_subscription s ON s.subconflictlogrelid = c.oid
+WHERE s.subname = 'regress_conflict_test1';
+ nspname | pg_relation_is_publishable
+-------------+----------------------------
+ pg_conflict | f
+(1 row)
+
+--
+-- Table Protection and Lifecycle Management
+--
+-- These tests verify that:
+-- Manual DROP TABLE is disallowed.
+-- DROP SUBSCRIPTION automatically reaps the table.
+--
+-- re-enable table logging for verification
+ALTER SUBSCRIPTION regress_conflict_test1 SET (conflict_log_destination = 'table');
+-- fail - drop table not allowed due to internal dependency
+-- use DO block to hide OID in error message
+DO $$
+BEGIN
+ EXECUTE 'DROP TABLE ' || (SELECT 'pg_conflict.pg_conflict_' || oid FROM pg_subscription WHERE subname = 'regress_conflict_test1');
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege';
+END $$;
+NOTICE: captured expected error: insufficient_privilege
+-- CLEANUP: DROP SUBSCRIPTION reaps the table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+-- Verify the table OID for reap check
+SELECT 'pg_conflict_' || oid AS internal_tablename FROM pg_subscription WHERE subname = 'regress_conflict_test1' \gset
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the internal table was reaped via dependency
+SELECT to_regclass(:'internal_tablename');
+ to_regclass
+-------------
+
+(1 row)
+
+--
+-- Additional Namespace and Table Protection Tests
+--
+-- Setup: Ensure we have a subscription with a conflict log table
+CREATE SUBSCRIPTION regress_conflict_protection_test CONNECTION 'dbname=regress_doesnotexist'
+ PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- Trying to ALTER the internal conflict log table
+-- This should fail because the table is system-managed.
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ RAISE NOTICE 'Attempting ALTER TABLE on internal conflict table';
+ EXECUTE 'ALTER TABLE ' || tab_name || ' ADD COLUMN extra_info text';
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during ALTER';
+END $$;
+NOTICE: Attempting ALTER TABLE on internal conflict table
+NOTICE: captured expected error: insufficient_privilege during ALTER
+-- Trying to TRUNCATE the internal conflict log table
+-- This should be allowed.
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ RAISE NOTICE 'Attempting TRUNCATE on internal conflict table';
+ EXECUTE 'TRUNCATE ' || tab_name;
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during TRUNCATE';
+END $$;
+NOTICE: Attempting TRUNCATE on internal conflict table
+NOTICE: captured expected error: insufficient_privilege during TRUNCATE
+-- Trying to create a new table manually in the pg_conflict namespace
+-- This should fail as the namespace is reserved for conflict logs tables.
+CREATE TABLE pg_conflict.manual_table (id int);
+ERROR: permission denied to create "pg_conflict.manual_table"
+DETAIL: System catalog modifications are currently disallowed.
+-- Moving an existing table into the pg_conflict namespace
+-- Users should not be able to move their own tables within this namespace.
+CREATE TABLE public.test_move (id int);
+ALTER TABLE public.test_move SET SCHEMA pg_conflict;
+ERROR: cannot move objects into or out of CONFLICT schema
+DROP TABLE public.test_move;
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_log_default DISABLE;
+ALTER SUBSCRIPTION regress_conflict_log_default SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_log_default;
+ALTER SUBSCRIPTION regress_conflict_empty_str DISABLE;
+ALTER SUBSCRIPTION regress_conflict_empty_str SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_empty_str;
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+ALTER SUBSCRIPTION regress_conflict_protection_test DISABLE;
+ALTER SUBSCRIPTION regress_conflict_protection_test SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_protection_test;
RESET SESSION AUTHORIZATION;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index ef0c298d2df..e1304ddf8c3 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -365,6 +365,195 @@ COMMIT;
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG DESTINATION TESTS
+--
+
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+
+-- fail - unrecognized parameter value
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
+
+-- verify subconflictlogdest is 'log' and relid is 0 (InvalidOid) for default case
+CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
+
+-- verify empty string defaults to 'log'
+CREATE SUBSCRIPTION regress_conflict_empty_str CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = '');
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
+
+-- this should generate an internal table named pg_conflict_$subid$
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+
+-- check metadata in pg_subscription: destination should be 'table' and relid valid
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- verify the physical table exists, its OID matches subconflictlogrelid,
+-- and it is located in the 'pg_conflict' namespace.
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+JOIN pg_namespace n ON c.relnamespace = n.oid
+WHERE s.subname = 'regress_conflict_test1'
+ AND c.oid = s.subconflictlogrelid
+ AND n.nspname = 'pg_conflict';
+
+-- check if the internal table has the correct schema (11 columns)
+SELECT count(*)
+FROM pg_attribute a
+JOIN pg_class c ON a.attrelid = c.oid
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0;
+
+--
+-- ALTER SUBSCRIPTION - conflict_log_destination state transitions
+--
+-- These tests verify the transition logic between different logging
+-- destinations, ensuring internal tables are created or dropped as expected.
+--
+
+-- transition from 'log' to 'all'
+-- a new internal conflict log table should be created
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
+
+-- verify metadata after ALTER (destination should be 'all')
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'all' to 'table'
+-- should NOT drop the table, only change destination string
+SELECT subconflictlogrelid AS old_relid FROM pg_subscription WHERE subname = 'regress_conflict_test2' \gset
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'table');
+SELECT subconflictlogdest, subconflictlogrelid = :old_relid AS relid_unchanged
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'table' to 'log'
+-- should drop the table and clear relid
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log');
+SELECT subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- verify the physical table is gone
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test2';
+
+--
+-- PUBLICATION: Verify internal tables are not publishable
+--
+-- pg_relation_is_publishable should return false for internal conflict log
+-- tables to prevent them from being accidentally included in publications.
+--
+SELECT n.nspname, pg_relation_is_publishable(c.oid)
+FROM pg_class c
+JOIN pg_namespace n ON c.relnamespace = n.oid
+JOIN pg_subscription s ON s.subconflictlogrelid = c.oid
+WHERE s.subname = 'regress_conflict_test1';
+
+--
+-- Table Protection and Lifecycle Management
+--
+-- These tests verify that:
+-- Manual DROP TABLE is disallowed.
+-- DROP SUBSCRIPTION automatically reaps the table.
+--
+-- re-enable table logging for verification
+ALTER SUBSCRIPTION regress_conflict_test1 SET (conflict_log_destination = 'table');
+
+-- fail - drop table not allowed due to internal dependency
+-- use DO block to hide OID in error message
+DO $$
+BEGIN
+ EXECUTE 'DROP TABLE ' || (SELECT 'pg_conflict.pg_conflict_' || oid FROM pg_subscription WHERE subname = 'regress_conflict_test1');
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege';
+END $$;
+
+-- CLEANUP: DROP SUBSCRIPTION reaps the table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+
+-- Verify the table OID for reap check
+SELECT 'pg_conflict_' || oid AS internal_tablename FROM pg_subscription WHERE subname = 'regress_conflict_test1' \gset
+
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the internal table was reaped via dependency
+SELECT to_regclass(:'internal_tablename');
+
+--
+-- Additional Namespace and Table Protection Tests
+--
+
+-- Setup: Ensure we have a subscription with a conflict log table
+CREATE SUBSCRIPTION regress_conflict_protection_test CONNECTION 'dbname=regress_doesnotexist'
+ PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+
+-- Trying to ALTER the internal conflict log table
+-- This should fail because the table is system-managed.
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ RAISE NOTICE 'Attempting ALTER TABLE on internal conflict table';
+ EXECUTE 'ALTER TABLE ' || tab_name || ' ADD COLUMN extra_info text';
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during ALTER';
+END $$;
+
+-- Trying to TRUNCATE the internal conflict log table
+-- This should be allowed.
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ RAISE NOTICE 'Attempting TRUNCATE on internal conflict table';
+ EXECUTE 'TRUNCATE ' || tab_name;
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during TRUNCATE';
+END $$;
+
+-- Trying to create a new table manually in the pg_conflict namespace
+-- This should fail as the namespace is reserved for conflict logs tables.
+CREATE TABLE pg_conflict.manual_table (id int);
+
+-- Moving an existing table into the pg_conflict namespace
+-- Users should not be able to move their own tables within this namespace.
+CREATE TABLE public.test_move (id int);
+ALTER TABLE public.test_move SET SCHEMA pg_conflict;
+DROP TABLE public.test_move;
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_log_default DISABLE;
+ALTER SUBSCRIPTION regress_conflict_log_default SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_log_default;
+
+ALTER SUBSCRIPTION regress_conflict_empty_str DISABLE;
+ALTER SUBSCRIPTION regress_conflict_empty_str SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_empty_str;
+
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+
+ALTER SUBSCRIPTION regress_conflict_protection_test DISABLE;
+ALTER SUBSCRIPTION regress_conflict_protection_test SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_protection_test;
+
+
RESET SESSION AUTHORIZATION;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 09e7f1d420e..4b46c669919 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -500,6 +500,8 @@ ConditionVariableMinimallyPadded
ConditionalStack
ConfigData
ConfigVariable
+ConflictLogColumnDef
+ConflictLogDest
ConflictTupleInfo
ConflictType
ConnCacheEntry
--
2.49.0
[application/octet-stream] v20-0003-Doccumentation-patch.patch (10.4K, ../../CAFiTN-vj8NTm9w_L2XdhxJCub_RZw__YVUgfXa1B1kJzJctRNw@mail.gmail.com/4-v20-0003-Doccumentation-patch.patch)
download | inline diff:
From 609b6825ec8a90b7ad66bbec5831f809103c7a2f Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Sun, 21 Dec 2025 18:51:57 +0530
Subject: [PATCH v20 3/3] Doccumentation patch
---
doc/src/sgml/logical-replication.sgml | 116 +++++++++++++++++++++-
doc/src/sgml/ref/alter_subscription.sgml | 14 ++-
doc/src/sgml/ref/create_subscription.sgml | 34 +++++++
3 files changed, 161 insertions(+), 3 deletions(-)
diff --git a/doc/src/sgml/logical-replication.sgml b/doc/src/sgml/logical-replication.sgml
index 68d6efe5114..41517bf2716 100644
--- a/doc/src/sgml/logical-replication.sgml
+++ b/doc/src/sgml/logical-replication.sgml
@@ -289,6 +289,19 @@
option of <command>CREATE SUBSCRIPTION</command> for details.
</para>
+ <para>
+ Conflicts that occur during replication are, by default, logged as plain text
+ in the server log, which can make automated monitoring and analysis difficult.
+ The <command>CREATE SUBSCRIPTION</command> command provides the
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination">
+ <literal>conflict_log_destination</literal></link> option to record detailed
+ conflict information in a structured, queryable format. When this parameter
+ is set to <literal>table</literal> or <literal>all</literal>, the system
+ automatically manages a dedicated conflict log table, which is created and
+ dropped along with the subscription. This significantly improves post-mortem
+ analysis and operational visibility of the replication setup.
+ </para>
+
<sect2 id="logical-replication-subscription-slot">
<title>Logical Replication Slot Management</title>
@@ -2118,7 +2131,98 @@ Publications:
</para>
<para>
- The log format for logical replication conflicts is as follows:
+ When the <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>
+ is set to <literal>table</literal> or <literal>all</literal>, the system
+ automatically creates a new table with a predefined schema to log conflict
+ details. This table is created in the dedicated
+ <literal>pg_conflict</literal> namespace. The name of the conflict log table
+ is pg_conflict_<replaceable>subscription_oid</replaceable>. The schema of this
+ table is detailed in
+ <xref linkend="logical-replication-conflict-log-schema"/>.
+ </para>
+
+ <table id="logical-replication-conflict-log-schema">
+ <title>Conflict Log Table Schema</title>
+ <tgroup cols="3">
+ <thead>
+ <row>
+ <entry>Column</entry>
+ <entry>Type</entry>
+ <entry>Description</entry>
+ </row>
+ </thead>
+ <tbody>
+ <row>
+ <entry><literal>relid</literal></entry>
+ <entry><type>oid</type></entry>
+ <entry>The OID of the local table where the conflict occurred.</entry>
+ </row>
+ <row>
+ <entry><literal>schemaname</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The schema name of the conflicting table.</entry>
+ </row>
+ <row>
+ <entry><literal>relname</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The name of the conflicting table.</entry>
+ </row>
+ <row>
+ <entry><literal>conflict_type</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The type of conflict that occurred (e.g., <literal>insert_exists</literal>).</entry>
+ </row>
+ <row>
+ <entry><literal>remote_xid</literal></entry>
+ <entry><type>xid</type></entry>
+ <entry>The remote transaction ID that caused the conflict.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_commit_lsn</literal></entry>
+ <entry><type>pg_lsn</type></entry>
+ <entry>The final LSN of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_commit_ts</literal></entry>
+ <entry><type>timestamptz</type></entry>
+ <entry>The remote commit timestamp of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_origin</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The origin of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_tuple</literal></entry>
+ <entry><type>json</type></entry>
+ <entry>The JSON representation of the incoming remote row that caused the conflict.</entry>
+ </row>
+ <row>
+ <entry><literal>local_conflicts</literal></entry>
+ <entry><type>json[]</type></entry>
+ <entry>
+ An array of JSON objects representing the local state for each conflict attempt.
+ Each object includes the local transaction ID (<literal>xid</literal>), commit
+ timestamp (<literal>commit_ts</literal>), origin (<literal>origin</literal>),
+ conflicting key data (<literal>key</literal>), and the full local row
+ image (<literal>tuple</literal>).
+ </entry>
+ </row>
+ </tbody>
+ </tgroup>
+ </table>
+
+ <para>
+ The conflicting row data, including the original local tuple and
+ the remote tuple, is stored in <type>JSON</type> columns (<literal>local_tuple</literal>
+ and <literal>remote_tuple</literal>) for flexible querying and analysis.
+ </para>
+
+ <para>
+ If <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>
+ is left at the default setting or explicitly configured as <literal>log</literal>
+ or <literal>all</literal>, logical replication conflicts are logged in the
+ following format:
<synopsis>
LOG: conflict detected on relation "<replaceable>schemaname</replaceable>.<replaceable>tablename</replaceable>": conflict=<replaceable>conflict_type</replaceable>
DETAIL: <replaceable class="parameter">detailed_explanation</replaceable>.
@@ -2412,6 +2516,16 @@ CONTEXT: processing remote data for replication origin "pg_16395" during "INSER
key or replica identity defined for it.
</para>
</listitem>
+
+ <listitem>
+ <para>
+ The internal table automatically created when
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>
+ is set to <literal>table</literal> or <literal>all</literal> is excluded from
+ logical replication. It will not be published, even if a publication on the
+ subscriber is defined using <literal>FOR ALL TABLES</literal>.
+ </para>
+ </listitem>
</itemizedlist>
</sect1>
diff --git a/doc/src/sgml/ref/alter_subscription.sgml b/doc/src/sgml/ref/alter_subscription.sgml
index 27c06439f4f..2de2c3c52fb 100644
--- a/doc/src/sgml/ref/alter_subscription.sgml
+++ b/doc/src/sgml/ref/alter_subscription.sgml
@@ -280,8 +280,9 @@ ALTER SUBSCRIPTION <replaceable class="parameter">name</replaceable> RENAME TO <
<link linkend="sql-createsubscription-params-with-origin"><literal>origin</literal></link>,
<link linkend="sql-createsubscription-params-with-failover"><literal>failover</literal></link>,
<link linkend="sql-createsubscription-params-with-two-phase"><literal>two_phase</literal></link>,
- <link linkend="sql-createsubscription-params-with-retain-dead-tuples"><literal>retain_dead_tuples</literal></link>, and
- <link linkend="sql-createsubscription-params-with-max-retention-duration"><literal>max_retention_duration</literal></link>.
+ <link linkend="sql-createsubscription-params-with-retain-dead-tuples"><literal>retain_dead_tuples</literal></link>,
+ <link linkend="sql-createsubscription-params-with-max-retention-duration"><literal>max_retention_duration</literal></link> and,
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>.
Only a superuser can set <literal>password_required = false</literal>.
</para>
@@ -339,6 +340,15 @@ ALTER SUBSCRIPTION <replaceable class="parameter">name</replaceable> RENAME TO <
<quote><literal>pg_conflict_detection</literal></quote>, created to retain
dead tuples for conflict detection, will be dropped.
</para>
+
+ <para>
+ When the <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>
+ parameter is set to <literal>table</literal> or <literal>all</literal>, the system
+ automatically creates the internal conflict log table if it does not already
+ exist. Conversely, if the destination is changed to
+ <literal>log</literal>, logging to the table stops and the internal
+ table is automatically dropped.
+ </para>
</listitem>
</varlistentry>
diff --git a/doc/src/sgml/ref/create_subscription.sgml b/doc/src/sgml/ref/create_subscription.sgml
index b7dd361294b..05e4a700eff 100644
--- a/doc/src/sgml/ref/create_subscription.sgml
+++ b/doc/src/sgml/ref/create_subscription.sgml
@@ -247,6 +247,40 @@ CREATE SUBSCRIPTION <replaceable class="parameter">subscription_name</replaceabl
</listitem>
</varlistentry>
+ <varlistentry id="sql-createsubscription-params-with-conflict-log-destination">
+ <term><literal>conflict_log_destination</literal> (<type>enum</type>)</term>
+ <listitem>
+ <para>
+ Specifies the destination for recording logical replication conflicts.
+ </para>
+ <para>
+ The available destinations are:
+ <itemizedlist>
+ <listitem>
+ <para>
+ <literal>log</literal>: Conflict details are recorded in the server log.
+ This is the default behavior.
+ </para>
+ </listitem>
+ <listitem>
+ <para>
+ <literal>table</literal>: The system automatically creates a structured table
+ named <literal>pg_conflict_<subid></literal> in the
+ <literal>pg_conflict</literal> schema. This allows for easy querying and
+ analysis of conflicts. This table is automatically dropped when the
+ subscription is removed.
+ </para>
+ </listitem>
+ <listitem>
+ <para>
+ <literal>all</literal>: Records to both the server log and the table.
+ </para>
+ </listitem>
+ </itemizedlist>
+ </para>
+ </listitem>
+ </varlistentry>
+
<varlistentry id="sql-createsubscription-params-with-copy-data">
<term><literal>copy_data</literal> (<type>boolean</type>)</term>
<listitem>
--
2.49.0
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-12 02:21 Amit Kapila <amit.kapila16@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Amit Kapila @ 2026-01-12 02:21 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Fri, Jan 9, 2026 at 7:13 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Fri, Jan 9, 2026 at 12:42 PM vignesh C <vignesh21@gmail.com> wrote:
> >
> > On Mon, 5 Jan 2026 at 15:13, Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > Here is the updated version of patch
> >
> > I would like to suggest renaming the newly introduced system schema
> > from pg_conflict to pg_logical or something in lines of logical
> > replication.
> > Although the immediate use case is conflict logging, the schema is
> > fundamentally part of the logical replication subsystem. A broader
> > name such as pg_logical provides a more appropriate and future proof
> > namespace. In contrast, a feature-specific name like pg_conflict is
> > risky as logical replication evolves and could necessitate the
> > introduction of additional system schemas later. Looking ahead,
> > advanced features such as DDL replication or multi-node writes would
> > likely require metadata for cluster topology of multiple nodes, leader
> > state, peer discovery, and resolution policies for node failure. This
> > type of replication specific metadata would naturally fit under a
> > pg_logical system schema rather than pg_catalog.
> >
> > Finally, adopting pg_logical would leave open the possibility of
> > logically grouping existing logical replication catalogs and views
> > (publications, subscriptions, and slot related information) under a
> > single subsystem namespace, instead of having them scattered across
> > pg_catalog.
>
> I agree with this analysis of making it future proof what others think
> about this? Although we might not be clear now what permission
> differences would be needed for future metadata tables compared to
> conflict tables, those could be managed if we get the generic name.
>
Why do you think this additional meta-data can't reside in the
pg_catalog schema as we have for other system tables? IIUC, we are
planning to have a pg_conflict schema for the conflict history table
because we want a different treatment for pg_dump (like we want to
dump its data during upgrade) and some permissions for user access to
it (say for purging data from this table). AFAICS, the other meta-data
mentioned above shouldn't have any such specific needs unless I am
missing something. It is not that I am against naming it differently
or using some generic name but I can't see a reason for it. Instead,
following a path like we already have for pg_toast (where schema name
indicates its purpose) sounds like a better fit.
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-12 05:26 Peter Smith <smithpb2250@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
4 siblings, 1 reply; 912+ messages in thread
From: Peter Smith @ 2026-01-12 05:26 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Some review comments for the v20-0001 patch.
======
0. General
Applying the patch gives whitespace warnings:
$ git apply ../patches_misc/v20-0001-Add-configurable-conflict-log-table-for-Logical-.patch
../patches_misc/v20-0001-Add-configurable-conflict-log-table-for-Logical-.patch:1543:
trailing whitespace.
CREATE SUBSCRIPTION regress_conflict_protection_test CONNECTION
'dbname=regress_doesnotexist'
../patches_misc/v20-0001-Add-configurable-conflict-log-table-for-Logical-.patch:610:
new blank line at EOF.
+
warning: 2 lines add whitespace errors.
======
src/backend/catalog/pg_publication.c
1.
+
+ /* Can't be conflict log table */
+ if (IsConflictNamespace(RelationGetNamespace(targetrel)))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot add relation \"%s.%s\" to publication",
+ get_namespace_name(RelationGetNamespace(targetrel)),
+ RelationGetRelationName(targetrel)),
+ errdetail("This operation is not supported for conflict log tables.")));
I felt it may be better to still keep the function
IsConflictLogTable(). You can just put this namespace logic inside
that function. Then one day, if other tables are added to that special
schema, at least the impact for checking CLT is contained.
~~~
is_publishable_relation:
2.
/*
* Another variant of is_publishable_class(), taking a Relation.
+ *
+ * Note: Conflict log tables are not publishable. However, we intentionally
+ * skip this check here because this function is called for every change and
+ * performing this check during every change publication is costly. To ensure
+ * unpublishable entries are ignored without incurring performance overhead,
+ * tuples inserted into the conflict log table uses the HEAP_INSERT_NO_LOGICAL
+ * flag. This allows the decoding layer to bypass these entries automatically.
Is that comment still relevant, now that is_publishable_class() has
been changed to use IsConflictClass()?
~~~
pg_relation_is_publishable:
3.
+
+ /* Subscription conflict log tables are not published */
result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple));
The comment seems unnecessary/misplaced because there is no special
code anymore in this function for CLT.
~~~
GetAllPublicationRelations:
4.
+ /* Subscription conflict log tables are not published */
if (is_publishable_class(relid, relForm) &&
The comment seems unnecessary/misplaced because there is no special
code anymore in this function for CLT.
======
src/include/replication/conflict.h
5.
+extern Relation GetConflictLogTableInfo(ConflictLogDest *log_dest);
+extern void InsertConflictLogTuple(Relation conflictlogrel);
This change seems to be in the wrong patch. afaict these functions are
not implemented in patch 0001.
======
src/test/regress/sql/subscription.sql
6.
+-- verify the physical table exists, its OID matches subconflictlogrelid,
+-- and it is located in the 'pg_conflict' namespace.
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+JOIN pg_namespace n ON c.relnamespace = n.oid
+WHERE s.subname = 'regress_conflict_test1'
+ AND c.oid = s.subconflictlogrelid
+ AND n.nspname = 'pg_conflict';
+ count
+-------
+ 1
+(1 row)
+
For this kind of test, I wondered if it would be better to make the
SQL read more like the comment says. You can put some of the WHERE
conditions directly in the SELECT column list and just let the
regression comparisons with 'expected' results take care of validating
them.
e.g.
SUGGESTION
-- verify the physical table exists, its OID matches subconflictlogrelid,
-- and it is located in the 'pg_conflict' namespace.
SELECT n.nspname, (c.oid = s.subconflictlogrelid) AS "Oid matches"
FROM pg_class c
JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE s.subname = 'regress_conflict_test1';
nspname | Oid matches
-------------+-------------
pg_conflict | t
(1 row)
~~~
7.
Similarly, for the CLT metadata test, instead of only expecting 11
rows, why not just SELECT/compare all the attributes more generically?
e.g.
SUGGESTION
-- check if the internal table has the correct schema
SELECT a.attnum, a.attname
FROM pg_attribute a
JOIN pg_class c ON a.attrelid = c.oid
JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0
ORDER BY a.attnum;
attnum | attname
--------+-------------------
1 | relid
2 | schemaname
3 | relname
4 | conflict_type
5 | remote_xid
6 | remote_commit_lsn
7 | remote_commit_ts
8 | remote_origin
9 | replica_identity
10 | remote_tuple
11 | local_conflicts
(11 rows)
~~~
8.
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege
during ALTER';
IIUC, the test was only written that way because the CLT name is
dynamically based on the <subid>, so you cannot know it up-front to
include that name in the 'expected' error message. Maybe there could
be a comment to explain that?
~~~
9.
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege
during TRUNCATE';
Ditto previous review comment.
======
Kind Regards,
Peter Smith.
Fujitsu Australia
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-12 05:29 Peter Smith <smithpb2250@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
4 siblings, 1 reply; 912+ messages in thread
From: Peter Smith @ 2026-01-12 05:29 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Some review comments for patch v20-0003 (docs)
======
doc/src/sgml/logical-replication.sgml
(29.8. Conflicts)
1.
+ When the <link
linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>
+ is set to <literal>table</literal> or <literal>all</literal>, the system
+ automatically creates a new table with a predefined schema to log conflict
+ details. This table is created in the dedicated
+ <literal>pg_conflict</literal> namespace. The name of the
conflict log table
+ is pg_conflict_<replaceable>subscription_oid</replaceable>. The
schema of this
+ table is detailed in
+ <xref linkend="logical-replication-conflict-log-schema"/>.
+ </para>
1a.
Instead of saying "When the conflict_log_destination is ...", maybe it
should say "When the conflict_log_destination parameter is ...".
That should be more consistent with the wording used elsewhere in this patch.
~
1b.
But, on the CREATE SUBSCRIPTION page, the table name is called:
<literal>pg_conflict_<subid></literal>
Both places should refer to the name using the same format -- the
CREATE SUBSCRIPTION way looked good to me.
~~~
2.
+ The conflicting row data, including the original local tuple and
+ the remote tuple, is stored in <type>JSON</type> columns
(<literal>local_tuple</literal>
+ and <literal>remote_tuple</literal>) for flexible querying and analysis.
+ </para>
As reported previously [1, comment #5], I didn't see any column called
'local_tuple' -- did you mean to refer to the elements of the
'local_conflicts' array here?
~~~
3.
A previous review comment [1, comment #6 ] suggesting making some
Chapter 29 supsection/s about "Logging conflicts" was not addressed.
You disagreed?
======
doc/src/sgml/ref/create_subscription.sgml
4.
A previous review comment [1, comment #13 ] suggesting adding some
links to refer to Chapter 29 for the logging details was not
addressed. You disagreed?
======
[1] my v19-0003 review -
https://www.postgresql.org/message-id/CAHut%2BPtu9-R6x5t%3D2aXdVUR-cjopGxYFEgOjHpUY1jsAfG1drA%40mail...
Kind Regards,
Peter Smith.
Fujitsu Australia
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-12 06:34 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 0 replies; 912+ messages in thread
From: shveta malik @ 2026-01-12 06:34 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; Peter Smith <smithpb2250@gmail.com>; shveta malik <shveta.malik@gmail.com>
On Fri, Jan 9, 2026 at 4:49 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Thu, Jan 8, 2026 at 12:30 PM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > On Wed, Jan 7, 2026 at 12:12 PM shveta malik <shveta.malik@gmail.com> wrote:
> > >
> > > Hi Dilip,
> > > Please find a few comments on v19-001:
> > >
> >
> > Please find a few comments for v19-002's part I have reviewed so far:
> >
> > 1)
> > ReportApplyConflict:
> > + if ((dest & CONFLICT_LOG_DEST_TABLE) != 0)
> > + if ((dest & CONFLICT_LOG_DEST_LOG) != 0)
> >
> > We can use IsSet everywhere
>
> IMHO in subscriptioncmd.c we very frequently use the bitwise operation
> so it has IsSet declared, I am not really sure do we want to define
> IsSet in this function as we are using it just 2 places.
>
> > 2)
> > GetConflictLogTableInfo
> > This function gets dest and opens table, shall we rename to :
> > GetConflictLogDestAndTable
>
> Yeah make sense.
>
> > 3)
> > GetConflictLogTableInfo:
> > + *log_dest = GetLogDestination(MySubscription->conflictlogdest);
> > + conflictlogrelid = MySubscription->conflictlogrelid;
> > +
> > + /* If destination is 'log' only, no table to open. */
> > + if (*log_dest == CONFLICT_LOG_DEST_LOG)
> > + return NULL;
> > +
> > + Assert(OidIsValid(conflictlogrelid));
> >
> > We don't need to fetch conflictlogrelid until after 'if (*log_dest ==
> > CONFLICT_LOG_DEST_LOG)' check. We shall move it after the 'if' check.
>
> Done
>
> > 4)
> > GetConflictLogTableInfo:
> > + /* Conflict log table is dropped or not accessible. */
> > + if (conflictlogrel == NULL)
> > + ereport(WARNING,
> > + (errcode(ERRCODE_UNDEFINED_TABLE),
> > + errmsg("conflict log table with OID %u does not exist",
> > + conflictlogrelid)));
> >
> > Shall we replace it with elog(ERROR)? IMO, it should never happen and
> > if it happens, we should raise it as an internal error as we do for
> > various other cases.
>
> Right, now its internal table so we should make it elog(ERROR)
>
> >
> > 5)
> > ReportApplyConflict():
> >
> > Currently the function structure is:
> >
> > /* Insert to table if destination is 'table' or 'all' */
> > if ((dest & CONFLICT_LOG_DEST_TABLE) != 0)
> >
> > /* Decide what detail to show in server logs. */
> > if ((dest & CONFLICT_LOG_DEST_LOG) != 0)
> > else <table-only: put reduced info in log>
> >
> >
> > It will be good to make it:
> >
> > /*
> > * Insert to table if destination is 'table' or 'all' and
> > * also log the error msg to serverlog
> > */
> > if ((dest & CONFLICT_LOG_DEST_TABLE) != 0)
> > {...}
> > else <CONFLICT_LOG_DEST_LOG case>
> > {log complete detail}
>
> I did not understand this, I mean we can not put the "log complete
> detail" case in the else part, because we might have to log the
> complete detail along with the table if the destination is "all", are
> you suggesting something else?
Okay, I see it now. I missed the 'all' part earlier. Thanks for
pointing it out. The current implementation is good.
>
> >
> > 6)
> > tuple_table_slot_to_indextup_json:
> > + tuple = heap_form_tuple(tupdesc, values, isnull);
> >
> > Do we need to do: heap_freetuple at the end?
>
> Yeah better we do that, instead of assuming short lived memory context.
>
>
> --
> Regards,
> Dilip Kumar
> Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-12 07:09 Peter Smith <smithpb2250@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
4 siblings, 0 replies; 912+ messages in thread
From: Peter Smith @ 2026-01-12 07:09 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Some review comments for v20-0002 (code)
======
src/backend/replication/logical/conflict.c
ReportApplyConflict:
1.
+ /* Insert to table if requested. */
+ if ((dest & CONFLICT_LOG_DEST_TABLE) != 0)
+ {
...
+ }
+ /* Decide what detail to show in server logs. */
+ if ((dest & CONFLICT_LOG_DEST_LOG) != 0)
+ {
...
+ /* Standard reporting with full internal details. */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail_internal("%s", err_detail.data));
+ }
+ else
+ {
+ /*
+ * 'table' only: Report the error msg but omit raw tuple data from
+ * server logs since it's already captured in the internal table.
+ */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail("Conflict details logged to internal table with OID %u.",
+ MySubscription->conflictlogrelid));
+ }
OK, I understand better now what you are trying to do with the logging
-- e.g. I see the brief log is referring to the CLT. But, it seems a
bit strange still that the 'else' of the LOG is asserting there *must*
be a CLT. Perhaps that is a valid assumption today, but if another
kind of destination is supported in the future, then I doubt this
logic is correct.
How about using some variables, like this:
SUGGESTION
bool log_dest_clt = (dest & CONFLICT_LOG_DEST_TABLE) != 0);
bool log_dest_logfile = (dest & CONFLICT_LOG_DEST_LOG) != 0);
if (log_dest_clt)
{
/* Logging to the CLT */
...
if (!log_dest_logfile)
{
/* Not logging conflict details to the server log; instead, write
a brief message referencing this CLT. */
ereport(elevel, ...
errdetail("Conflict details logged to internal table with OID %u.", ...);
}
}
if (log_dest_logfile)
{
/* Logging to the Server Log */
...
}
~~~
build_local_conflicts_json_array:
2.
+ json_datum_array = (Datum *) palloc_array(Datum, num_conflicts);
+ json_null_array = (bool *) palloc0_array(bool, num_conflicts);
The casts are redundant. The macros are taking care of that.
======
src/include/replication/conflict.h
3.
-extern Relation GetConflictLogTableInfo(ConflictLogDest *log_dest);
+extern Relation GetConflictLogDestAndTable(ConflictLogDest *log_dest);
+extern void InsertConflictLogTuple(Relation conflictlogrel);
+extern bool ValidateConflictLogTable(Relation rel);
3a.
AFAICT GetConflictLogTableInfo() should not have existed anyway.
~
3b.
AFAICT ValidateConflictLogTable() is not used anymore. This seems
leftover from some old patch version.
======
Kind Regards,
Peter Smith.
Fujitsu Australia
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-12 11:48 Dilip Kumar <dilipbalaut@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2026-01-12 11:48 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Mon, Jan 12, 2026 at 7:51 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Fri, Jan 9, 2026 at 7:13 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Fri, Jan 9, 2026 at 12:42 PM vignesh C <vignesh21@gmail.com> wrote:
> > >
> > > On Mon, 5 Jan 2026 at 15:13, Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > >
> > > > Here is the updated version of patch
> > >
> > > I would like to suggest renaming the newly introduced system schema
> > > from pg_conflict to pg_logical or something in lines of logical
> > > replication.
> > > Although the immediate use case is conflict logging, the schema is
> > > fundamentally part of the logical replication subsystem. A broader
> > > name such as pg_logical provides a more appropriate and future proof
> > > namespace. In contrast, a feature-specific name like pg_conflict is
> > > risky as logical replication evolves and could necessitate the
> > > introduction of additional system schemas later. Looking ahead,
> > > advanced features such as DDL replication or multi-node writes would
> > > likely require metadata for cluster topology of multiple nodes, leader
> > > state, peer discovery, and resolution policies for node failure. This
> > > type of replication specific metadata would naturally fit under a
> > > pg_logical system schema rather than pg_catalog.
> > >
> > > Finally, adopting pg_logical would leave open the possibility of
> > > logically grouping existing logical replication catalogs and views
> > > (publications, subscriptions, and slot related information) under a
> > > single subsystem namespace, instead of having them scattered across
> > > pg_catalog.
> >
> > I agree with this analysis of making it future proof what others think
> > about this? Although we might not be clear now what permission
> > differences would be needed for future metadata tables compared to
> > conflict tables, those could be managed if we get the generic name.
> >
>
> Why do you think this additional meta-data can't reside in the
> pg_catalog schema as we have for other system tables? IIUC, we are
> planning to have a pg_conflict schema for the conflict history table
> because we want a different treatment for pg_dump (like we want to
> dump its data during upgrade) and some permissions for user access to
> it (say for purging data from this table).
Yeah that make sense.
AFAICS, the other meta-data
> mentioned above shouldn't have any such specific needs unless I am
> missing something. It is not that I am against naming it differently
> or using some generic name but I can't see a reason for it. Instead,
> following a path like we already have for pg_toast (where schema name
> indicates its purpose) sounds like a better fit.
So maybe we should continue with the pg_conflict name itself. Lets
see what Vignesh has to say as he has raised this concern.
--
Dilip
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-12 14:32 Dilip Kumar <dilipbalaut@gmail.com>
parent: Peter Smith <smithpb2250@gmail.com>
0 siblings, 0 replies; 912+ messages in thread
From: Dilip Kumar @ 2026-01-12 14:32 UTC (permalink / raw)
To: Peter Smith <smithpb2250@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Mon, Jan 12, 2026 at 10:56 AM Peter Smith <smithpb2250@gmail.com> wrote:
>
> Some review comments for the v20-0001 patch.
>
> ======
> 0. General
>
> Applying the patch gives whitespace warnings:
>
> $ git apply ../patches_misc/v20-0001-Add-configurable-conflict-log-table-for-Logical-.patch
> ../patches_misc/v20-0001-Add-configurable-conflict-log-table-for-Logical-.patch:1543:
> trailing whitespace.
> CREATE SUBSCRIPTION regress_conflict_protection_test CONNECTION
> 'dbname=regress_doesnotexist'
> ../patches_misc/v20-0001-Add-configurable-conflict-log-table-for-Logical-.patch:610:
> new blank line at EOF.
> +
> warning: 2 lines add whitespace errors.
Fixed
> ======
> src/backend/catalog/pg_publication.c
>
> 1.
> +
> + /* Can't be conflict log table */
> + if (IsConflictNamespace(RelationGetNamespace(targetrel)))
> + ereport(ERROR,
> + (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
> + errmsg("cannot add relation \"%s.%s\" to publication",
> + get_namespace_name(RelationGetNamespace(targetrel)),
> + RelationGetRelationName(targetrel)),
> + errdetail("This operation is not supported for conflict log tables.")));
>
> I felt it may be better to still keep the function
> IsConflictLogTable(). You can just put this namespace logic inside
> that function. Then one day, if other tables are added to that special
> schema, at least the impact for checking CLT is contained.
I prefer not to keep that additional function, anyway all the checks
are done using isConflictClass and friends, and in future if we need
to change where something else can come in this conflict namespace it
wouldn't be hard to change the logic.
> ~~~
>
> is_publishable_relation:
>
> 2.
> /*
> * Another variant of is_publishable_class(), taking a Relation.
> + *
> + * Note: Conflict log tables are not publishable. However, we intentionally
> + * skip this check here because this function is called for every change and
> + * performing this check during every change publication is costly. To ensure
> + * unpublishable entries are ignored without incurring performance overhead,
> + * tuples inserted into the conflict log table uses the HEAP_INSERT_NO_LOGICAL
> + * flag. This allows the decoding layer to bypass these entries automatically.
>
> Is that comment still relevant, now that is_publishable_class() has
> been changed to use IsConflictClass()?
Yeah this is not relevant anymore.
> ~~~
>
> pg_relation_is_publishable:
>
> 3.
> +
> + /* Subscription conflict log tables are not published */
> result = is_publishable_class(relid, (Form_pg_class) GETSTRUCT(tuple));
>
> The comment seems unnecessary/misplaced because there is no special
> code anymore in this function for CLT.
Right
> ~~~
>
> GetAllPublicationRelations:
>
> 4.
> + /* Subscription conflict log tables are not published */
> if (is_publishable_class(relid, relForm) &&
>
> The comment seems unnecessary/misplaced because there is no special
> code anymore in this function for CLT.
Fixed
> ======
> src/include/replication/conflict.h
>
> 5.
> +extern Relation GetConflictLogTableInfo(ConflictLogDest *log_dest);
> +extern void InsertConflictLogTuple(Relation conflictlogrel);
>
> This change seems to be in the wrong patch. afaict these functions are
> not implemented in patch 0001.
Fixed
> ======
> src/test/regress/sql/subscription.sql
>
> 6.
> +-- verify the physical table exists, its OID matches subconflictlogrelid,
> +-- and it is located in the 'pg_conflict' namespace.
> +SELECT count(*)
> +FROM pg_class c
> +JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
> +JOIN pg_namespace n ON c.relnamespace = n.oid
> +WHERE s.subname = 'regress_conflict_test1'
> + AND c.oid = s.subconflictlogrelid
> + AND n.nspname = 'pg_conflict';
> + count
> +-------
> + 1
> +(1 row)
> +
>
> For this kind of test, I wondered if it would be better to make the
> SQL read more like the comment says. You can put some of the WHERE
> conditions directly in the SELECT column list and just let the
> regression comparisons with 'expected' results take care of validating
> them.
>
> e.g.
> SUGGESTION
> -- verify the physical table exists, its OID matches subconflictlogrelid,
> -- and it is located in the 'pg_conflict' namespace.
> SELECT n.nspname, (c.oid = s.subconflictlogrelid) AS "Oid matches"
> FROM pg_class c
> JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
> JOIN pg_namespace n ON c.relnamespace = n.oid
> WHERE s.subname = 'regress_conflict_test1';
> nspname | Oid matches
> -------------+-------------
> pg_conflict | t
> (1 row)
>
> ~~~
>
> 7.
> Similarly, for the CLT metadata test, instead of only expecting 11
> rows, why not just SELECT/compare all the attributes more generically?
>
> e.g.
> SUGGESTION
> -- check if the internal table has the correct schema
> SELECT a.attnum, a.attname
> FROM pg_attribute a
> JOIN pg_class c ON a.attrelid = c.oid
> JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
> WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0
> ORDER BY a.attnum;
> attnum | attname
> --------+-------------------
> 1 | relid
> 2 | schemaname
> 3 | relname
> 4 | conflict_type
> 5 | remote_xid
> 6 | remote_commit_lsn
> 7 | remote_commit_ts
> 8 | remote_origin
> 9 | replica_identity
> 10 | remote_tuple
> 11 | local_conflicts
> (11 rows)
Make sense
> ~~~
>
> 8.
> +EXCEPTION WHEN insufficient_privilege THEN
> + RAISE NOTICE 'captured expected error: insufficient_privilege
> during ALTER';
>
> IIUC, the test was only written that way because the CLT name is
> dynamically based on the <subid>, so you cannot know it up-front to
> include that name in the 'expected' error message. Maybe there could
> be a comment to explain that?
Right that's the reason, added comments for the same
> ~~~
>
> 9.
> +EXCEPTION WHEN insufficient_privilege THEN
> + RAISE NOTICE 'captured expected error: insufficient_privilege
> during TRUNCATE';
>
> Ditto previous review comment.
Done
Will send updated patch after checking comments in other emails.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-12 21:54 Peter Smith <smithpb2250@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
4 siblings, 1 reply; 912+ messages in thread
From: Peter Smith @ 2026-01-12 21:54 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Hi Dilip.
Some comments about the v20-0002 (test code only)
======
src/test/subscription/t/035_conflicts.pl
1.
+my $subid = $node_subscriber->safe_psql('postgres',
+ "SELECT oid FROM pg_subscription WHERE subname = 'sub_tab';");
+my $conflict_table = "pg_conflict.pg_conflict_$subid";
1a.
Maybe there should be a comment somewhere near here to describe that
the remainder of this test part is for verifying the contents of the
CLT.
~
1b.
Consider renaming the variable '$conflict_table' as '$clt', so it
cannot be confused with "the table that has the conflict".
~~~
2.
+# Wait for the conflict to be logged
+my $log_check = $node_subscriber->poll_query_until(
+ 'postgres',
+ "SELECT count(*) > 0 FROM $conflict_table;"
+);
/to be logged/to be logged in the CLT/
~~~
3.
+# Verify that '2' is present in the resulting string
+like($all_keys, qr/2/, 'Verified that key 2 exists in the
local_conflicts log');
The check just for '2' seemed vague. IIUC, this would also match 12,
25, 102, or anything else with a 2 in it. Is it possible for this test
to compare a longer string so it is guaranteed to be a more unique
match for what you are expecting?
~~~
4.
+# Wait for the conflict to be logged
+$log_check = $node_subscriber->poll_query_until(
+ 'postgres',
+ "SELECT count(*) > 0 FROM $conflict_table;"
+);
+
+$conflict_check = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM $conflict_table WHERE conflict_type =
'multiple_unique_conflicts';");
+is($conflict_check, 1, 'Verified multiple_unique_conflicts logged
into internal table');
+
+$json_query = qq[
+ SELECT string_agg((unnested.j::json)->'key'->>'a', ',')
+ FROM (
+ SELECT unnest(local_conflicts) AS j
+ FROM $conflict_table
+ ) AS unnested;
+];
+
+$all_keys = $node_subscriber->safe_psql('postgres', $json_query);
+
+# Verify that '6' is present in the resulting string
+like($all_keys, qr/6/, 'Verified that key 6 exists in the
local_conflicts log');
+
All the same review comments above apply here, too.
- Comment to say all this is for verifying the CLT
- /to be logged/to be logged in the CLT/
- The check just for '6' seemed vague.
======
Kind Regards,
Peter Smith.
Fujitsu Australia
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-13 10:28 Dilip Kumar <dilipbalaut@gmail.com>
parent: Peter Smith <smithpb2250@gmail.com>
0 siblings, 3 replies; 912+ messages in thread
From: Dilip Kumar @ 2026-01-13 10:28 UTC (permalink / raw)
To: Peter Smith <smithpb2250@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, Jan 13, 2026 at 3:25 AM Peter Smith <smithpb2250@gmail.com> wrote:
>
> Hi Dilip.
>
> Some comments about the v20-0002 (test code only)
>
> ======
> src/test/subscription/t/035_conflicts.pl
>
> 1.
> +my $subid = $node_subscriber->safe_psql('postgres',
> + "SELECT oid FROM pg_subscription WHERE subname = 'sub_tab';");
> +my $conflict_table = "pg_conflict.pg_conflict_$subid";
>
> 1a.
> Maybe there should be a comment somewhere near here to describe that
> the remainder of this test part is for verifying the contents of the
> CLT.
Done
> ~
>
> 1b.
> Consider renaming the variable '$conflict_table' as '$clt', so it
> cannot be confused with "the table that has the conflict".
Done
> ~~~
>
> 2.
> +# Wait for the conflict to be logged
> +my $log_check = $node_subscriber->poll_query_until(
> + 'postgres',
> + "SELECT count(*) > 0 FROM $conflict_table;"
> +);
>
> /to be logged/to be logged in the CLT/
Done
> ~~~
> 3.
> +# Verify that '2' is present in the resulting string
> +like($all_keys, qr/2/, 'Verified that key 2 exists in the
> local_conflicts log');
>
> The check just for '2' seemed vague. IIUC, this would also match 12,
> 25, 102, or anything else with a 2 in it. Is it possible for this test
> to compare a longer string so it is guaranteed to be a more unique
> match for what you are expecting?
How about matching specifically 2 using like($all_keys, qr/\b2\b/,
'Verified that key 2 exists in the local_conflicts log'); ?
> ~~~
>
> 4.
> +# Wait for the conflict to be logged
> +$log_check = $node_subscriber->poll_query_until(
> + 'postgres',
> + "SELECT count(*) > 0 FROM $conflict_table;"
> +);
> +
> +$conflict_check = $node_subscriber->safe_psql('postgres',
> + "SELECT count(*) FROM $conflict_table WHERE conflict_type =
> 'multiple_unique_conflicts';");
> +is($conflict_check, 1, 'Verified multiple_unique_conflicts logged
> into internal table');
> +
> +$json_query = qq[
> + SELECT string_agg((unnested.j::json)->'key'->>'a', ',')
> + FROM (
> + SELECT unnest(local_conflicts) AS j
> + FROM $conflict_table
> + ) AS unnested;
> +];
> +
> +$all_keys = $node_subscriber->safe_psql('postgres', $json_query);
> +
> +# Verify that '6' is present in the resulting string
> +like($all_keys, qr/6/, 'Verified that key 6 exists in the
> local_conflicts log');
> +
>
> All the same review comments above apply here, too.
>
> - Comment to say all this is for verifying the CLT
> - /to be logged/to be logged in the CLT/
> - The check just for '6' seemed vague.
Done same treatment as above.
I am not sure does it makes sense to check the tuple from clt for all
the test cases in 035_conflict.pl or just checking in a few of them is
sufficient? Thoughts?
--
Regards,
Dilip Kumar
Google
Attachments:
[application/octet-stream] v21-0003-Doccumentation-patch.patch (10.4K, ../../CAFiTN-tam8fzawYc4VckPGQU9uH5iKRJXDFOLGTS9xgrdv3UNA@mail.gmail.com/2-v21-0003-Doccumentation-patch.patch)
download | inline diff:
From 37e3bede7eb1e44f1e7070431f273310620c93ca Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Sun, 21 Dec 2025 18:51:57 +0530
Subject: [PATCH v21 3/3] Doccumentation patch
---
doc/src/sgml/logical-replication.sgml | 116 +++++++++++++++++++++-
doc/src/sgml/ref/alter_subscription.sgml | 14 ++-
doc/src/sgml/ref/create_subscription.sgml | 34 +++++++
3 files changed, 161 insertions(+), 3 deletions(-)
diff --git a/doc/src/sgml/logical-replication.sgml b/doc/src/sgml/logical-replication.sgml
index 68d6efe5114..41517bf2716 100644
--- a/doc/src/sgml/logical-replication.sgml
+++ b/doc/src/sgml/logical-replication.sgml
@@ -289,6 +289,19 @@
option of <command>CREATE SUBSCRIPTION</command> for details.
</para>
+ <para>
+ Conflicts that occur during replication are, by default, logged as plain text
+ in the server log, which can make automated monitoring and analysis difficult.
+ The <command>CREATE SUBSCRIPTION</command> command provides the
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination">
+ <literal>conflict_log_destination</literal></link> option to record detailed
+ conflict information in a structured, queryable format. When this parameter
+ is set to <literal>table</literal> or <literal>all</literal>, the system
+ automatically manages a dedicated conflict log table, which is created and
+ dropped along with the subscription. This significantly improves post-mortem
+ analysis and operational visibility of the replication setup.
+ </para>
+
<sect2 id="logical-replication-subscription-slot">
<title>Logical Replication Slot Management</title>
@@ -2118,7 +2131,98 @@ Publications:
</para>
<para>
- The log format for logical replication conflicts is as follows:
+ When the <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>
+ is set to <literal>table</literal> or <literal>all</literal>, the system
+ automatically creates a new table with a predefined schema to log conflict
+ details. This table is created in the dedicated
+ <literal>pg_conflict</literal> namespace. The name of the conflict log table
+ is pg_conflict_<replaceable>subscription_oid</replaceable>. The schema of this
+ table is detailed in
+ <xref linkend="logical-replication-conflict-log-schema"/>.
+ </para>
+
+ <table id="logical-replication-conflict-log-schema">
+ <title>Conflict Log Table Schema</title>
+ <tgroup cols="3">
+ <thead>
+ <row>
+ <entry>Column</entry>
+ <entry>Type</entry>
+ <entry>Description</entry>
+ </row>
+ </thead>
+ <tbody>
+ <row>
+ <entry><literal>relid</literal></entry>
+ <entry><type>oid</type></entry>
+ <entry>The OID of the local table where the conflict occurred.</entry>
+ </row>
+ <row>
+ <entry><literal>schemaname</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The schema name of the conflicting table.</entry>
+ </row>
+ <row>
+ <entry><literal>relname</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The name of the conflicting table.</entry>
+ </row>
+ <row>
+ <entry><literal>conflict_type</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The type of conflict that occurred (e.g., <literal>insert_exists</literal>).</entry>
+ </row>
+ <row>
+ <entry><literal>remote_xid</literal></entry>
+ <entry><type>xid</type></entry>
+ <entry>The remote transaction ID that caused the conflict.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_commit_lsn</literal></entry>
+ <entry><type>pg_lsn</type></entry>
+ <entry>The final LSN of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_commit_ts</literal></entry>
+ <entry><type>timestamptz</type></entry>
+ <entry>The remote commit timestamp of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_origin</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The origin of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_tuple</literal></entry>
+ <entry><type>json</type></entry>
+ <entry>The JSON representation of the incoming remote row that caused the conflict.</entry>
+ </row>
+ <row>
+ <entry><literal>local_conflicts</literal></entry>
+ <entry><type>json[]</type></entry>
+ <entry>
+ An array of JSON objects representing the local state for each conflict attempt.
+ Each object includes the local transaction ID (<literal>xid</literal>), commit
+ timestamp (<literal>commit_ts</literal>), origin (<literal>origin</literal>),
+ conflicting key data (<literal>key</literal>), and the full local row
+ image (<literal>tuple</literal>).
+ </entry>
+ </row>
+ </tbody>
+ </tgroup>
+ </table>
+
+ <para>
+ The conflicting row data, including the original local tuple and
+ the remote tuple, is stored in <type>JSON</type> columns (<literal>local_tuple</literal>
+ and <literal>remote_tuple</literal>) for flexible querying and analysis.
+ </para>
+
+ <para>
+ If <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>
+ is left at the default setting or explicitly configured as <literal>log</literal>
+ or <literal>all</literal>, logical replication conflicts are logged in the
+ following format:
<synopsis>
LOG: conflict detected on relation "<replaceable>schemaname</replaceable>.<replaceable>tablename</replaceable>": conflict=<replaceable>conflict_type</replaceable>
DETAIL: <replaceable class="parameter">detailed_explanation</replaceable>.
@@ -2412,6 +2516,16 @@ CONTEXT: processing remote data for replication origin "pg_16395" during "INSER
key or replica identity defined for it.
</para>
</listitem>
+
+ <listitem>
+ <para>
+ The internal table automatically created when
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>
+ is set to <literal>table</literal> or <literal>all</literal> is excluded from
+ logical replication. It will not be published, even if a publication on the
+ subscriber is defined using <literal>FOR ALL TABLES</literal>.
+ </para>
+ </listitem>
</itemizedlist>
</sect1>
diff --git a/doc/src/sgml/ref/alter_subscription.sgml b/doc/src/sgml/ref/alter_subscription.sgml
index 27c06439f4f..2de2c3c52fb 100644
--- a/doc/src/sgml/ref/alter_subscription.sgml
+++ b/doc/src/sgml/ref/alter_subscription.sgml
@@ -280,8 +280,9 @@ ALTER SUBSCRIPTION <replaceable class="parameter">name</replaceable> RENAME TO <
<link linkend="sql-createsubscription-params-with-origin"><literal>origin</literal></link>,
<link linkend="sql-createsubscription-params-with-failover"><literal>failover</literal></link>,
<link linkend="sql-createsubscription-params-with-two-phase"><literal>two_phase</literal></link>,
- <link linkend="sql-createsubscription-params-with-retain-dead-tuples"><literal>retain_dead_tuples</literal></link>, and
- <link linkend="sql-createsubscription-params-with-max-retention-duration"><literal>max_retention_duration</literal></link>.
+ <link linkend="sql-createsubscription-params-with-retain-dead-tuples"><literal>retain_dead_tuples</literal></link>,
+ <link linkend="sql-createsubscription-params-with-max-retention-duration"><literal>max_retention_duration</literal></link> and,
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>.
Only a superuser can set <literal>password_required = false</literal>.
</para>
@@ -339,6 +340,15 @@ ALTER SUBSCRIPTION <replaceable class="parameter">name</replaceable> RENAME TO <
<quote><literal>pg_conflict_detection</literal></quote>, created to retain
dead tuples for conflict detection, will be dropped.
</para>
+
+ <para>
+ When the <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>
+ parameter is set to <literal>table</literal> or <literal>all</literal>, the system
+ automatically creates the internal conflict log table if it does not already
+ exist. Conversely, if the destination is changed to
+ <literal>log</literal>, logging to the table stops and the internal
+ table is automatically dropped.
+ </para>
</listitem>
</varlistentry>
diff --git a/doc/src/sgml/ref/create_subscription.sgml b/doc/src/sgml/ref/create_subscription.sgml
index b7dd361294b..05e4a700eff 100644
--- a/doc/src/sgml/ref/create_subscription.sgml
+++ b/doc/src/sgml/ref/create_subscription.sgml
@@ -247,6 +247,40 @@ CREATE SUBSCRIPTION <replaceable class="parameter">subscription_name</replaceabl
</listitem>
</varlistentry>
+ <varlistentry id="sql-createsubscription-params-with-conflict-log-destination">
+ <term><literal>conflict_log_destination</literal> (<type>enum</type>)</term>
+ <listitem>
+ <para>
+ Specifies the destination for recording logical replication conflicts.
+ </para>
+ <para>
+ The available destinations are:
+ <itemizedlist>
+ <listitem>
+ <para>
+ <literal>log</literal>: Conflict details are recorded in the server log.
+ This is the default behavior.
+ </para>
+ </listitem>
+ <listitem>
+ <para>
+ <literal>table</literal>: The system automatically creates a structured table
+ named <literal>pg_conflict_<subid></literal> in the
+ <literal>pg_conflict</literal> schema. This allows for easy querying and
+ analysis of conflicts. This table is automatically dropped when the
+ subscription is removed.
+ </para>
+ </listitem>
+ <listitem>
+ <para>
+ <literal>all</literal>: Records to both the server log and the table.
+ </para>
+ </listitem>
+ </itemizedlist>
+ </para>
+ </listitem>
+ </varlistentry>
+
<varlistentry id="sql-createsubscription-params-with-copy-data">
<term><literal>copy_data</literal> (<type>boolean</type>)</term>
<listitem>
--
2.49.0
[application/octet-stream] v21-0002-Implement-the-conflict-insertion-infrastructure-.patch (28.8K, ../../CAFiTN-tam8fzawYc4VckPGQU9uH5iKRJXDFOLGTS9xgrdv3UNA@mail.gmail.com/3-v21-0002-Implement-the-conflict-insertion-infrastructure-.patch)
download | inline diff:
From 633170653f788dfd5ae4e293058fa0c93b8f5b85 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Mon, 12 Jan 2026 20:21:03 +0530
Subject: [PATCH v21 2/3] Implement the conflict insertion infrastructure for
the conflict log table
This patch introduces the core logic to populate the conflict log table whenever
a logical replication conflict is detected. It captures the remote transaction
details along with the corresponding local state at the time of the conflict.
Handling Multi-row Conflicts: A single remote tuple may conflict with multiple
local tuples (e.g., in the case of multiple_unique_conflicts). To handle this,
the infrastructure creates a single row in the conflict log table for each
remote tuple. The details of all conflicting local rows are aggregated into a
single JSON array in the local_conflicts column.
The JSON array uses the following structured format:
[ { "xid": "1001", "commit_ts": "2025-12-25 10:00:00+05:30", "origin": "node_1",
"key": {"id": 1}, "tuple": {"id": 1, "val": "old_data"} }, ... ]
Example of querying the structured conflict data:
SELECT remote_xid, relname, remote_origin, local_conflicts[1] ->> 'xid' AS local_xid,
local_conflicts[1] ->> 'tuple' AS local_tuple
FROM myschema.conflict_log_history2;
remote_xid | relname | remote_origin | local_xid | local_tuple
------------+----------+---------------+-----------+---------------------
760 | test | pg_16406 | 771 | {"a":1,"b":10}
765 | conf_tab | pg_16406 | 775 | {"a":2,"b":2,"c":2}
---
src/backend/replication/logical/conflict.c | 560 +++++++++++++++++++--
src/backend/replication/logical/launcher.c | 1 +
src/backend/replication/logical/worker.c | 31 +-
src/include/replication/conflict.h | 3 +
src/include/replication/worker_internal.h | 7 +
src/test/subscription/t/035_conflicts.pl | 60 ++-
6 files changed, 617 insertions(+), 45 deletions(-)
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 93222ee3b88..d05af2fbd6f 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -15,13 +15,20 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/tableam.h"
+#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "pgstat.h"
#include "replication/conflict.h"
#include "replication/worker_internal.h"
#include "storage/lmgr.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/lsyscache.h"
+#include "utils/pg_lsn.h"
+#include "utils/jsonb.h"
static const char *const ConflictTypeNames[] = {
[CT_INSERT_EXISTS] = "insert_exists",
@@ -34,6 +41,18 @@ static const char *const ConflictTypeNames[] = {
[CT_MULTIPLE_UNIQUE_CONFLICTS] = "multiple_unique_conflicts"
};
+/* Schema for the elements within the 'local_conflicts' JSON array */
+static const ConflictLogColumnDef LocalConflictSchema[] =
+{
+ { .attname = "xid", .atttypid = XIDOID },
+ { .attname = "commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "origin", .atttypid = TEXTOID },
+ { .attname = "key", .atttypid = JSONOID },
+ { .attname = "tuple", .atttypid = JSONOID }
+};
+
+#define MAX_LOCAL_CONFLICT_INFO_ATTRS lengthof(LocalConflictSchema)
+
static int errcode_apply_conflict(ConflictType type);
static void errdetail_apply_conflict(EState *estate,
ResultRelInfo *relinfo,
@@ -50,8 +69,27 @@ static char *build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *localslot,
TupleTableSlot *remoteslot,
Oid indexoid);
+static void build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull);
static char *build_index_value_desc(EState *estate, Relation localrel,
TupleTableSlot *slot, Oid indexoid);
+static Datum tuple_table_slot_to_json_datum(TupleTableSlot *slot);
+static Datum tuple_table_slot_to_indextup_json(EState *estate,
+ Relation localrel,
+ Oid replica_index,
+ TupleTableSlot *slot);
+static TupleDesc build_conflict_tupledesc(void);
+static Datum build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples);
+static void prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot);
/*
* Get the xmin and commit timestamp data (origin and timestamp) associated
@@ -105,30 +143,90 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
ConflictType type, TupleTableSlot *searchslot,
TupleTableSlot *remoteslot, List *conflicttuples)
{
- Relation localrel = relinfo->ri_RelationDesc;
- StringInfoData err_detail;
+ Relation localrel = relinfo->ri_RelationDesc;
+ ConflictLogDest dest;
+ Relation conflictlogrel;
+ bool log_dest_clt;
+ bool log_dest_logfile;
- initStringInfo(&err_detail);
+ pgstat_report_subscription_conflict(MySubscription->oid, type);
- /* Form errdetail message by combining conflicting tuples information. */
- foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
- errdetail_apply_conflict(estate, relinfo, type, searchslot,
- conflicttuple->slot, remoteslot,
- conflicttuple->indexoid,
- conflicttuple->xmin,
- conflicttuple->origin,
- conflicttuple->ts,
- &err_detail);
+ /*
+ * Get both the conflict log destination and the opened conflict log
+ * relation for insertion.
+ */
+ conflictlogrel = GetConflictLogDestAndTable(&dest);
- pgstat_report_subscription_conflict(MySubscription->oid, type);
+ log_dest_clt = ((dest & CONFLICT_LOG_DEST_TABLE) != 0);
+ log_dest_logfile = ((dest & CONFLICT_LOG_DEST_LOG) != 0);
+
+ /* Insert to table if requested. */
+ if (log_dest_clt)
+ {
+ Assert(conflictlogrel != NULL);
+
+ /*
+ * Prepare the conflict log tuple. If the error level is below ERROR,
+ * insert it immediately. Otherwise, defer the insertion to a new
+ * transaction after the current one aborts, ensuring the insertion of
+ * the log tuple is not rolled back.
+ */
+ prepare_conflict_log_tuple(estate,
+ relinfo->ri_RelationDesc,
+ conflictlogrel,
+ type,
+ searchslot,
+ conflicttuples,
+ remoteslot);
+ if (elevel < ERROR)
+ InsertConflictLogTuple(conflictlogrel);
+
+ table_close(conflictlogrel, RowExclusiveLock);
+
+ if (!log_dest_logfile)
+ {
+ /*
+ * Not logging conflict details to the server log; Report the error
+ * msg but omit raw tuple data from server logs since it's already
+ * captured in the internal table.
+ */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail("Conflict details logged to internal table with OID %u.",
+ MySubscription->conflictlogrelid));
+ }
+ }
- ereport(elevel,
- errcode_apply_conflict(type),
- errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
- get_namespace_name(RelationGetNamespace(localrel)),
- RelationGetRelationName(localrel),
- ConflictTypeNames[type]),
- errdetail_internal("%s", err_detail.data));
+ /* Log into the server log if requested. */
+ if (log_dest_logfile)
+ {
+ StringInfoData err_detail;
+
+ initStringInfo(&err_detail);
+
+ /* Form errdetail message by combining conflicting tuples information. */
+ foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
+ errdetail_apply_conflict(estate, relinfo, type, searchslot,
+ conflicttuple->slot, remoteslot,
+ conflicttuple->indexoid,
+ conflicttuple->xmin,
+ conflicttuple->origin,
+ conflicttuple->ts,
+ &err_detail);
+
+ /* Standard reporting with full internal details. */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail_internal("%s", err_detail.data));
+ }
}
/*
@@ -162,6 +260,64 @@ InitConflictIndexes(ResultRelInfo *relInfo)
relInfo->ri_onConflictArbiterIndexes = uniqueIndexes;
}
+/*
+ * GetConflictLogDestAndTable
+ *
+ * Fetches conflict logging metadata from the cached MySubscription pointer.
+ * Sets the destination enum in *log_dest and, if applicable, opens and
+ * returns the relation handle for the internal log table.
+ */
+Relation
+GetConflictLogDestAndTable(ConflictLogDest *log_dest)
+{
+ Oid conflictlogrelid;
+ Relation conflictlogrel = NULL;
+
+ /*
+ * Convert the text log destination to the internal enum. MySubscription
+ * already contains the data from pg_subscription.
+ */
+ *log_dest = GetLogDestination(MySubscription->conflictlogdest);
+
+ /* Quick exit if a conflict log table was not requested. */
+ if ((*log_dest & CONFLICT_LOG_DEST_TABLE) == 0)
+ return NULL;
+
+ conflictlogrelid = MySubscription->conflictlogrelid;
+
+ Assert(OidIsValid(conflictlogrelid));
+
+ conflictlogrel = table_open(conflictlogrelid, RowExclusiveLock);
+ if (conflictlogrel == NULL)
+ elog(ERROR, "could not open conflict log table (OID %u)",
+ conflictlogrelid);
+
+ return conflictlogrel;
+}
+
+/*
+ * InsertConflictLogTuple
+ *
+ * Insert conflict log tuple into the conflict log table. It uses
+ * HEAP_INSERT_NO_LOGICAL to explicitly block logical decoding of the tuple
+ * inserted into the conflict log table.
+ */
+void
+InsertConflictLogTuple(Relation conflictlogrel)
+{
+ int options = HEAP_INSERT_NO_LOGICAL;
+
+ /* A valid tuple must be prepared and stored in MyLogicalRepWorker. */
+ Assert(MyLogicalRepWorker->conflict_log_tuple != NULL);
+
+ heap_insert(conflictlogrel, MyLogicalRepWorker->conflict_log_tuple,
+ GetCurrentCommandId(true), options, NULL);
+
+ /* Free conflict log tuple. */
+ heap_freetuple(MyLogicalRepWorker->conflict_log_tuple);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+}
+
/*
* Add SQLSTATE error code to the current conflict report.
*/
@@ -472,6 +628,40 @@ build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
return tuple_value.data;
}
+/*
+ * Helper function to extract the "raw" index key Datums and their null flags
+ * from a TupleTableSlot, given an already open index descriptor.
+ * This is the reusable core logic.
+ */
+static void
+build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull)
+{
+ TupleTableSlot *tableslot = slot;
+
+ /*
+ * If the slot is a virtual slot, copy it into a heap tuple slot as
+ * FormIndexDatum only works with heap tuple slots.
+ */
+ if (TTS_IS_VIRTUAL(slot))
+ {
+ /* Slot is created within the EState's tuple table */
+ tableslot = table_slot_create(localrel, &estate->es_tupleTable);
+ tableslot = ExecCopySlot(tableslot, slot);
+ }
+
+ /*
+ * Initialize ecxt_scantuple for potential use in FormIndexDatum
+ */
+ GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+
+ /* Form the index datums */
+ FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values,
+ isnull);
+}
+
/*
* Helper functions to construct a string describing the contents of an index
* entry. See BuildIndexValueDescription for details.
@@ -487,41 +677,325 @@ build_index_value_desc(EState *estate, Relation localrel, TupleTableSlot *slot,
Relation indexDesc;
Datum values[INDEX_MAX_KEYS];
bool isnull[INDEX_MAX_KEYS];
- TupleTableSlot *tableslot = slot;
- if (!tableslot)
+ if (!slot)
return NULL;
Assert(CheckRelationOidLockedByMe(indexoid, RowExclusiveLock, true));
indexDesc = index_open(indexoid, NoLock);
- /*
- * If the slot is a virtual slot, copy it into a heap tuple slot as
- * FormIndexDatum only works with heap tuple slots.
- */
- if (TTS_IS_VIRTUAL(slot))
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+
+ index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+
+ index_close(indexDesc, NoLock);
+
+ return index_value;
+}
+
+/*
+ * tuple_table_slot_to_json_datum
+ *
+ * Helper function to convert a TupleTableSlot to Jsonb.
+ */
+static Datum
+tuple_table_slot_to_json_datum(TupleTableSlot *slot)
+{
+ HeapTuple tuple;
+ Datum datum;
+ Datum json;
+
+ Assert(slot != NULL);
+
+ tuple = ExecCopySlotHeapTuple(slot);
+ datum = heap_copy_tuple_as_datum(tuple, slot->tts_tupleDescriptor);
+
+ json = DirectFunctionCall1(row_to_json, datum);
+ heap_freetuple(tuple);
+
+ return json;
+}
+
+/*
+ * tuple_table_slot_to_indextup_json
+ *
+ * Fetch replica identity key from the tuple table slot and convert into a
+ * jsonb datum.
+ */
+static Datum
+tuple_table_slot_to_indextup_json(EState *estate, Relation localrel,
+ Oid indexid, TupleTableSlot *slot)
+{
+ Relation indexDesc;
+ Datum values[INDEX_MAX_KEYS];
+ bool isnull[INDEX_MAX_KEYS];
+ HeapTuple tuple;
+ TupleDesc tupdesc;
+ Datum datum;
+
+ Assert(slot != NULL);
+
+ Assert(CheckRelationOidLockedByMe(indexid, RowExclusiveLock, true));
+
+ indexDesc = index_open(indexid, NoLock);
+
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+ tupdesc = RelationGetDescr(indexDesc);
+
+ /* Bless the tupdesc so it can be looked up by row_to_json. */
+ BlessTupleDesc(tupdesc);
+
+ /* Form the replica identity tuple. */
+ tuple = heap_form_tuple(tupdesc, values, isnull);
+ datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ index_close(indexDesc, NoLock);
+ heap_freetuple(tuple);
+
+ /* Convert to a JSONB datum. */
+ return DirectFunctionCall1(row_to_json, datum);
+}
+
+/*
+ * build_conflict_tupledesc
+ *
+ * Build and bless a tuple descriptor for the internal conflict log table
+ * based on the predefined LocalConflictSchema.
+ */
+static TupleDesc
+build_conflict_tupledesc(void)
+{
+ TupleDesc tupdesc;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ for (int i = 0; i < MAX_LOCAL_CONFLICT_INFO_ATTRS; i++)
+ TupleDescInitEntry(tupdesc, (AttrNumber) (i + 1),
+ LocalConflictSchema[i].attname,
+ LocalConflictSchema[i].atttypid,
+ -1, 0);
+
+ BlessTupleDesc(tupdesc);
+
+ return tupdesc;
+}
+
+/*
+ * Builds the local conflicts JSONB array column from the list of
+ * ConflictTupleInfo objects.
+ *
+ * Example output structure:
+ * [ { "xid": "1001", "commit_ts": "...", "origin": "...", "tuple": {...} }, ... ]
+ */
+static Datum
+build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples)
+{
+ ListCell *lc;
+ List *json_datums = NIL; /* List to hold the row_to_json results (type json) */
+ Datum *json_datum_array;
+ bool *json_null_array;
+ Datum json_array_datum;
+ int num_conflicts;
+ int i;
+ int16 typlen;
+ bool typbyval;
+ char typalign;
+ TupleDesc tupdesc;
+
+ /* Build local conflicts tuple descriptor. */
+ tupdesc = build_conflict_tupledesc();
+
+ /* Process local conflict tuple list and prepare an array of JSON. */
+ foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
{
- tableslot = table_slot_create(localrel, &estate->es_tupleTable);
- tableslot = ExecCopySlot(tableslot, slot);
+ Datum values[MAX_LOCAL_CONFLICT_INFO_ATTRS] = {0};
+ bool nulls[MAX_LOCAL_CONFLICT_INFO_ATTRS] = {0};
+ char *origin_name = NULL;
+ HeapTuple tuple;
+ Datum json_datum;
+ int attno;
+
+ attno = 0;
+ values[attno++] = TransactionIdGetDatum(conflicttuple->xmin);
+
+ if (conflicttuple->ts)
+ values[attno++] = TimestampTzGetDatum(conflicttuple->ts);
+ else
+ nulls[attno++] = true;
+
+ if (conflicttuple->origin != InvalidRepOriginId)
+ replorigin_by_oid(conflicttuple->origin, true, &origin_name);
+
+ /* Store empty string if origin name for the tuple is NULL. */
+ if (origin_name != NULL)
+ values[attno++] = CStringGetTextDatum(origin_name);
+ else
+ nulls[attno++] = true;
+
+ /*
+ * Add the conflicting key values in the case of a unique constraint
+ * violation.
+ */
+ if (conflict_type == CT_INSERT_EXISTS ||
+ conflict_type == CT_UPDATE_EXISTS ||
+ conflict_type == CT_MULTIPLE_UNIQUE_CONFLICTS)
+ {
+ Oid indexoid = conflicttuple->indexoid;
+
+ Assert(OidIsValid(indexoid) && conflicttuple->slot &&
+ CheckRelationOidLockedByMe(indexoid, RowExclusiveLock,
+ true));
+ values[attno++] =
+ tuple_table_slot_to_indextup_json(estate, rel,
+ indexoid,
+ conflicttuple->slot);
+ }
+ else
+ nulls[attno++] = true;
+
+ /* Convert conflicting tuple to JSON datum. */
+ if (conflicttuple->slot)
+ values[attno] = tuple_table_slot_to_json_datum(conflicttuple->slot);
+ else
+ nulls[attno] = true;
+
+ Assert(attno + 1 == MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ tuple = heap_form_tuple(tupdesc, values, nulls);
+
+ json_datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ /*
+ * Build the higher level JSON datum in format described in function
+ * header.
+ */
+ json_datum = DirectFunctionCall1(row_to_json, json_datum);
+
+ /* Done with the temporary tuple. */
+ heap_freetuple(tuple);
+
+ /* Add to the array element. */
+ json_datums = lappend(json_datums, (void *) json_datum);
}
- /*
- * Initialize ecxt_scantuple for potential use in FormIndexDatum when
- * index expressions are present.
- */
- GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+ num_conflicts = list_length(json_datums);
- /*
- * The values/nulls arrays passed to BuildIndexValueDescription should be
- * the results of FormIndexDatum, which are the "raw" input to the index
- * AM.
- */
- FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values, isnull);
+ json_datum_array = palloc_array(Datum, num_conflicts);
+ json_null_array = palloc0_array(bool, num_conflicts);
- index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+ i = 0;
+ foreach(lc, json_datums)
+ {
+ json_datum_array[i] = (Datum) lfirst(lc);
+ i++;
+ }
- index_close(indexDesc, NoLock);
+ /* Construct the json[] array Datum. */
+ get_typlenbyvalalign(JSONOID, &typlen, &typbyval, &typalign);
+ json_array_datum = PointerGetDatum(construct_array(json_datum_array,
+ num_conflicts,
+ JSONOID,
+ typlen,
+ typbyval,
+ typalign));
+ pfree(json_datum_array);
+ pfree(json_null_array);
+
+ return json_array_datum;
+}
- return index_value;
+/*
+ * prepare_conflict_log_tuple
+ *
+ * This routine prepares a tuple detailing a conflict encountered during
+ * logical replication. The prepared tuple will be stored in
+ * MyLogicalRepWorker->conflict_log_tuple which should be inserted into the
+ * conflict log table by calling InsertConflictLogTuple.
+ */
+static void
+prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot)
+{
+ Datum values[MAX_CONFLICT_ATTR_NUM] = {0};
+ bool nulls[MAX_CONFLICT_ATTR_NUM] = {0};
+ int attno;
+ char *remote_origin = NULL;
+ MemoryContext oldctx;
+
+ Assert(MyLogicalRepWorker->conflict_log_tuple == NULL);
+
+ /* Populate the values and nulls arrays. */
+ attno = 0;
+ values[attno++] = ObjectIdGetDatum(RelationGetRelid(rel));
+
+ values[attno++] =
+ CStringGetTextDatum(get_namespace_name(RelationGetNamespace(rel)));
+
+ values[attno++] = CStringGetTextDatum(RelationGetRelationName(rel));
+
+ values[attno++] = CStringGetTextDatum(ConflictTypeNames[conflict_type]);
+
+ if (TransactionIdIsValid(remote_xid))
+ values[attno++] = TransactionIdGetDatum(remote_xid);
+ else
+ nulls[attno++] = true;
+
+ values[attno++] = LSNGetDatum(remote_final_lsn);
+
+ if (remote_commit_ts > 0)
+ values[attno++] = TimestampTzGetDatum(remote_commit_ts);
+ else
+ nulls[attno++] = true;
+
+ if (replorigin_session_origin != InvalidRepOriginId)
+ replorigin_by_oid(replorigin_session_origin, true, &remote_origin);
+
+ if (remote_origin != NULL)
+ values[attno++] = CStringGetTextDatum(remote_origin);
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(searchslot))
+ {
+ Oid replica_index = GetRelationIdentityOrPK(rel);
+
+ /*
+ * If the table has a valid replica identity index, build the index
+ * json datum from key value. Otherwise, construct it from the complete
+ * tuple in REPLICA IDENTITY FULL cases.
+ */
+ if (OidIsValid(replica_index))
+ values[attno++] = tuple_table_slot_to_indextup_json(estate, rel,
+ replica_index,
+ searchslot);
+ else
+ values[attno++] = tuple_table_slot_to_json_datum(searchslot);
+ }
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(remoteslot))
+ values[attno++] = tuple_table_slot_to_json_datum(remoteslot);
+ else
+ nulls[attno++] = true;
+
+ values[attno] = build_local_conflicts_json_array(estate, rel,
+ conflict_type,
+ conflicttuples);
+
+ Assert(attno + 1 == MAX_CONFLICT_ATTR_NUM);
+
+ oldctx = MemoryContextSwitchTo(ApplyContext);
+ MyLogicalRepWorker->conflict_log_tuple =
+ heap_form_tuple(RelationGetDescr(conflictlogrel), values, nulls);
+ MemoryContextSwitchTo(oldctx);
}
diff --git a/src/backend/replication/logical/launcher.c b/src/backend/replication/logical/launcher.c
index 3ed86480be2..2dda5a44218 100644
--- a/src/backend/replication/logical/launcher.c
+++ b/src/backend/replication/logical/launcher.c
@@ -477,6 +477,7 @@ retry:
worker->oldest_nonremovable_xid = retain_dead_tuples
? MyReplicationSlot->data.xmin
: InvalidTransactionId;
+ worker->conflict_log_tuple = NULL;
worker->last_lsn = InvalidXLogRecPtr;
TIMESTAMP_NOBEGIN(worker->last_send_time);
TIMESTAMP_NOBEGIN(worker->last_recv_time);
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index ad281e7069b..d4be1122603 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -482,7 +482,9 @@ static bool MySubscriptionValid = false;
static List *on_commit_wakeup_workers_subids = NIL;
bool in_remote_transaction = false;
-static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+TransactionId remote_xid = InvalidTransactionId;
+TimestampTz remote_commit_ts = 0;
/* fields valid only when processing streamed transaction */
static bool in_streamed_transaction = false;
@@ -1219,6 +1221,8 @@ apply_handle_begin(StringInfo s)
set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
remote_final_lsn = begin_data.final_lsn;
+ remote_commit_ts = begin_data.committime;
+ remote_xid = begin_data.xid;
maybe_start_skipping_changes(begin_data.final_lsn);
@@ -1745,6 +1749,10 @@ apply_handle_stream_start(StringInfo s)
/* extract XID of the top-level transaction */
stream_xid = logicalrep_read_stream_start(s, &first_segment);
+ remote_xid = stream_xid;
+ remote_final_lsn = InvalidXLogRecPtr;
+ remote_commit_ts = 0;
+
if (!TransactionIdIsValid(stream_xid))
ereport(ERROR,
(errcode(ERRCODE_PROTOCOL_VIOLATION),
@@ -5609,6 +5617,27 @@ start_apply(XLogRecPtr origin_startpos)
pgstat_report_subscription_error(MySubscription->oid,
MyLogicalRepWorker->type);
+ /*
+ * Insert any pending conflict log tuple under a new transaction.
+ */
+ if (MyLogicalRepWorker->conflict_log_tuple != NULL)
+ {
+ Relation conflictlogrel;
+ ConflictLogDest dest;
+
+ StartTransactionCommand();
+ PushActiveSnapshot(GetTransactionSnapshot());
+
+ /* Open conflict log table and insert the tuple. */
+ conflictlogrel = GetConflictLogDestAndTable(&dest);
+ Assert((dest & CONFLICT_LOG_DEST_TABLE) != 0);
+ InsertConflictLogTuple(conflictlogrel);
+ table_close(conflictlogrel, RowExclusiveLock);
+
+ PopActiveSnapshot();
+ CommitTransactionCommand();
+ }
+
PG_RE_THROW();
}
}
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index ecdfacd4f00..009104b0503 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -144,4 +144,7 @@ extern void ReportApplyConflict(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *remoteslot,
List *conflicttuples);
extern void InitConflictIndexes(ResultRelInfo *relInfo);
+extern Relation GetConflictLogDestAndTable(ConflictLogDest *log_dest);
+extern void InsertConflictLogTuple(Relation conflictlogrel);
+extern bool ValidateConflictLogTable(Relation rel);
#endif
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index c1285fdd1bc..5bedfc5450f 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -101,6 +101,9 @@ typedef struct LogicalRepWorker
*/
TransactionId oldest_nonremovable_xid;
+ /* A conflict log tuple that is prepared but not yet inserted. */
+ HeapTuple conflict_log_tuple;
+
/* Stats. */
XLogRecPtr last_lsn;
TimestampTz last_send_time;
@@ -256,6 +259,10 @@ extern PGDLLIMPORT bool InitializingApplyWorker;
extern PGDLLIMPORT List *table_states_not_ready;
+extern XLogRecPtr remote_final_lsn;
+extern TimestampTz remote_commit_ts;
+extern TransactionId remote_xid;
+
extern void logicalrep_worker_attach(int slot);
extern LogicalRepWorker *logicalrep_worker_find(LogicalRepWorkerType wtype,
Oid subid, Oid relid,
diff --git a/src/test/subscription/t/035_conflicts.pl b/src/test/subscription/t/035_conflicts.pl
index ddc75e23fb0..4b2b36edba5 100644
--- a/src/test/subscription/t/035_conflicts.pl
+++ b/src/test/subscription/t/035_conflicts.pl
@@ -50,7 +50,7 @@ $node_subscriber->safe_psql(
'postgres',
"CREATE SUBSCRIPTION sub_tab
CONNECTION '$publisher_connstr application_name=$appname'
- PUBLICATION pub_tab;");
+ PUBLICATION pub_tab WITH (conflict_log_destination=all)");
# Wait for initial table sync to finish
$node_subscriber->wait_for_subscription_sync($node_publisher, $appname);
@@ -86,10 +86,41 @@ $node_subscriber->wait_for_log(
.*Key \(c\)=\(4\); existing local row \(4, 4, 4\); remote row \(2, 3, 4\)./,
$log_offset);
+# Verify the contents of the Conflict Log Table (CLT)
+# This section ensures that the clt contains the expected
+# type and specific key data.
+my $subid = $node_subscriber->safe_psql('postgres',
+ "SELECT oid FROM pg_subscription WHERE subname = 'sub_tab';");
+my $clt = "pg_conflict.pg_conflict_$subid";
+
+# Wait for the conflict to be logged in the CLT
+my $log_check = $node_subscriber->poll_query_until(
+ 'postgres',
+ "SELECT count(*) > 0 FROM $clt;"
+);
+
+my $conflict_check = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM $clt WHERE conflict_type = 'multiple_unique_conflicts';");
+is($conflict_check, 1, 'Verified multiple_unique_conflicts logged into internal table');
+
+my $json_query = qq[
+ SELECT string_agg((unnested.j::json)->'key'->>'a', ',')
+ FROM (
+ SELECT unnest(local_conflicts) AS j
+ FROM $clt
+ ) AS unnested;
+];
+
+my $all_keys = $node_subscriber->safe_psql('postgres', $json_query);
+
+# Verify that '2' is present in the resulting string
+like($all_keys, qr/\b2\b/, 'Verified that key 2 exists in the local_conflicts log');
+
pass('multiple_unique_conflicts detected during insert');
# Truncate table to get rid of the error
$node_subscriber->safe_psql('postgres', "TRUNCATE conf_tab;");
+$node_subscriber->safe_psql('postgres', "DELETE FROM $clt");
##################################################
# Test multiple_unique_conflicts due to UPDATE
@@ -118,6 +149,33 @@ $node_subscriber->wait_for_log(
.*Key \(c\)=\(8\); existing local row \(8, 8, 8\); remote row \(6, 7, 8\)./,
$log_offset);
+# Verify the contents of the Conflict Log Table (CLT)
+# This section ensures that the CLT contains the expected
+# type and specific key data.
+
+# Wait for the conflict to be logged in the CLT
+$log_check = $node_subscriber->poll_query_until(
+ 'postgres',
+ "SELECT count(*) > 0 FROM $clt;"
+);
+
+$conflict_check = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM $clt WHERE conflict_type = 'multiple_unique_conflicts';");
+is($conflict_check, 1, 'Verified multiple_unique_conflicts logged into internal table');
+
+$json_query = qq[
+ SELECT string_agg((unnested.j::json)->'key'->>'a', ',')
+ FROM (
+ SELECT unnest(local_conflicts) AS j
+ FROM $clt
+ ) AS unnested;
+];
+
+$all_keys = $node_subscriber->safe_psql('postgres', $json_query);
+
+# Verify that '6' is present in the resulting string
+like($all_keys, qr/\b6\b/, 'Verified that key 6 exists in the local_conflicts log');
+
pass('multiple_unique_conflicts detected during update');
# Truncate table to get rid of the error
--
2.49.0
[application/octet-stream] v21-0001-Add-configurable-conflict-log-table-for-Logical-.patch (106.6K, ../../CAFiTN-tam8fzawYc4VckPGQU9uH5iKRJXDFOLGTS9xgrdv3UNA@mail.gmail.com/4-v21-0001-Add-configurable-conflict-log-table-for-Logical-.patch)
download | inline diff:
From 8345f934473e01bcb66279569f85fc5e00e4c1d0 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 17 Dec 2025 11:53:47 +0530
Subject: [PATCH v21 1/3] Add configurable conflict log table for Logical
Replication
This patch adds a feature to provide a structured, queryable record of all
logical replication conflicts. The current approach of logging conflicts as
plain text in the server logs makes it difficult to query, analyze, and
use for external monitoring and automation.
This patch addresses these limitations by introducing a configurable
conflict_log_destination=('log'/'table'/'all') option in the CREATE SUBSCRIPTION
command.
If the user chooses to enable logging to a table (by selecting 'table' or 'all'),
an internal logging table named conflict_log_table_<subid> is automatically
created within a dedicated, system-managed namespace named pg_conflict. This table
is linked to the subscription via an internal dependency, ensuring it is
automatically dropped when the subscription is removed.
The conflict details, including the original and remote tuples, are stored in JSON
columns, providing a flexible format to accommodate different table schemas.
The log table captures essential attributes such as local and remote transaction IDs,
LSNs, commit timestamps, and conflict type, providing a complete record for post-mortem
analysis.
This feature will make logical replication conflicts easier to monitor and manage,
significantly improving the overall resilience and operability of replication setups.
The conflict log tables will not be included in a publication, even if the publication
is configured to include ALL TABLES or ALL TABLES IN SCHEMA.
---
src/backend/catalog/catalog.c | 27 +-
src/backend/catalog/heap.c | 3 +-
src/backend/catalog/namespace.c | 8 +-
src/backend/catalog/pg_publication.c | 15 +-
src/backend/catalog/pg_subscription.c | 7 +
src/backend/commands/subscriptioncmds.c | 239 +++++++++++-
src/bin/psql/describe.c | 21 +-
src/bin/psql/tab-complete.in.c | 8 +-
src/include/catalog/catalog.h | 2 +
src/include/catalog/pg_namespace.dat | 3 +
src/include/catalog/pg_subscription.h | 11 +
src/include/commands/subscriptioncmds.h | 3 +
src/include/replication/conflict.h | 55 +++
src/test/regress/expected/subscription.out | 421 ++++++++++++++++-----
src/test/regress/sql/subscription.sql | 194 ++++++++++
src/tools/pgindent/typedefs.list | 2 +
16 files changed, 913 insertions(+), 106 deletions(-)
diff --git a/src/backend/catalog/catalog.c b/src/backend/catalog/catalog.c
index 7be49032934..d438dc682ec 100644
--- a/src/backend/catalog/catalog.c
+++ b/src/backend/catalog/catalog.c
@@ -86,7 +86,8 @@ bool
IsSystemClass(Oid relid, Form_pg_class reltuple)
{
/* IsCatalogRelationOid is a bit faster, so test that first */
- return (IsCatalogRelationOid(relid) || IsToastClass(reltuple));
+ return (IsCatalogRelationOid(relid) || IsToastClass(reltuple)
+ || IsConflictClass(reltuple));
}
/*
@@ -230,6 +231,18 @@ IsToastClass(Form_pg_class reltuple)
return IsToastNamespace(relnamespace);
}
+/*
+ * IsConflictClass - Check if the given pg_class tuple belongs to the conflict
+ * namespace.
+ */
+bool
+IsConflictClass(Form_pg_class reltuple)
+{
+ Oid relnamespace = reltuple->relnamespace;
+
+ return IsConflictNamespace(relnamespace);
+}
+
/*
* IsCatalogNamespace
* True iff namespace is pg_catalog.
@@ -264,6 +277,18 @@ IsToastNamespace(Oid namespaceId)
isTempToastNamespace(namespaceId);
}
+/*
+ * IsConflictNamespace
+ * True iff namespace is pg_conflict.
+ *
+ * Does not perform any catalog accesses.
+ */
+bool
+IsConflictNamespace(Oid namespaceId)
+{
+ return namespaceId == PG_CONFLICT_NAMESPACE;
+}
+
/*
* IsReservedName
diff --git a/src/backend/catalog/heap.c b/src/backend/catalog/heap.c
index 606434823cf..10dadf378a4 100644
--- a/src/backend/catalog/heap.c
+++ b/src/backend/catalog/heap.c
@@ -314,7 +314,8 @@ heap_create(const char *relname,
*/
if (!allow_system_table_mods &&
((IsCatalogNamespace(relnamespace) && relkind != RELKIND_INDEX) ||
- IsToastNamespace(relnamespace)) &&
+ IsToastNamespace(relnamespace) ||
+ IsConflictNamespace(relnamespace)) &&
IsNormalProcessingMode())
ereport(ERROR,
(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
diff --git a/src/backend/catalog/namespace.c b/src/backend/catalog/namespace.c
index c3b79a2ba48..400292fd06b 100644
--- a/src/backend/catalog/namespace.c
+++ b/src/backend/catalog/namespace.c
@@ -3523,7 +3523,7 @@ LookupCreationNamespace(const char *nspname)
*
* We complain if either the old or new namespaces is a temporary schema
* (or temporary toast schema), or if either the old or new namespaces is the
- * TOAST schema.
+ * TOAST schema or the CONFLICT schema.
*/
void
CheckSetNamespace(Oid oldNspOid, Oid nspOid)
@@ -3539,6 +3539,12 @@ CheckSetNamespace(Oid oldNspOid, Oid nspOid)
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("cannot move objects into or out of TOAST schema")));
+
+ /* similarly for CONFLICT schema */
+ if (nspOid == PG_CONFLICT_NAMESPACE || oldNspOid == PG_CONFLICT_NAMESPACE)
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot move objects into or out of CONFLICT schema")));
}
/*
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index 9a4791c573e..de270ff1755 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -31,6 +31,7 @@
#include "catalog/pg_publication_rel.h"
#include "catalog/pg_type.h"
#include "commands/publicationcmds.h"
+#include "commands/subscriptioncmds.h"
#include "funcapi.h"
#include "utils/array.h"
#include "utils/builtins.h"
@@ -85,6 +86,15 @@ check_publication_add_relation(Relation targetrel)
errmsg("cannot add relation \"%s\" to publication",
RelationGetRelationName(targetrel)),
errdetail("This operation is not supported for unlogged tables.")));
+
+ /* Can't be conflict log table */
+ if (IsConflictNamespace(RelationGetNamespace(targetrel)))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot add relation \"%s.%s\" to publication",
+ get_namespace_name(RelationGetNamespace(targetrel)),
+ RelationGetRelationName(targetrel)),
+ errdetail("This operation is not supported for conflict log tables.")));
}
/*
@@ -95,7 +105,8 @@ static void
check_publication_add_schema(Oid schemaid)
{
/* Can't be system namespace */
- if (IsCatalogNamespace(schemaid) || IsToastNamespace(schemaid))
+ if (IsCatalogNamespace(schemaid) || IsToastNamespace(schemaid) ||
+ IsConflictNamespace(schemaid))
ereport(ERROR,
(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
errmsg("cannot add schema \"%s\" to publication",
@@ -139,6 +150,7 @@ is_publishable_class(Oid relid, Form_pg_class reltuple)
reltuple->relkind == RELKIND_PARTITIONED_TABLE ||
reltuple->relkind == RELKIND_SEQUENCE) &&
!IsCatalogRelationOid(relid) &&
+ !IsConflictClass(reltuple) &&
reltuple->relpersistence == RELPERSISTENCE_PERMANENT &&
relid >= FirstNormalObjectId;
}
@@ -890,6 +902,7 @@ GetAllPublicationRelations(char relkind, bool pubviaroot)
Form_pg_class relForm = (Form_pg_class) GETSTRUCT(tuple);
Oid relid = relForm->oid;
+ /* Subscription conflict log tables are not published */
if (is_publishable_class(relid, relForm) &&
!(relForm->relispartition && pubviaroot))
result = lappend_oid(result, relid);
diff --git a/src/backend/catalog/pg_subscription.c b/src/backend/catalog/pg_subscription.c
index 2b103245290..285a598497d 100644
--- a/src/backend/catalog/pg_subscription.c
+++ b/src/backend/catalog/pg_subscription.c
@@ -106,6 +106,7 @@ GetSubscription(Oid subid, bool missing_ok)
sub->retaindeadtuples = subform->subretaindeadtuples;
sub->maxretention = subform->submaxretention;
sub->retentionactive = subform->subretentionactive;
+ sub->conflictlogrelid = subform->subconflictlogrelid;
/* Get conninfo */
datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
@@ -141,6 +142,12 @@ GetSubscription(Oid subid, bool missing_ok)
Anum_pg_subscription_suborigin);
sub->origin = TextDatumGetCString(datum);
+ /* Get conflict log destination */
+ datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
+ tup,
+ Anum_pg_subscription_subconflictlogdest);
+ sub->conflictlogdest = TextDatumGetCString(datum);
+
/* Is the subscription owner a superuser? */
sub->ownersuperuser = superuser_arg(sub->owner);
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index d6674f20fc2..febf24d98cb 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -15,25 +15,31 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/htup_details.h"
#include "access/table.h"
#include "access/twophase.h"
#include "access/xact.h"
#include "catalog/catalog.h"
#include "catalog/dependency.h"
+#include "catalog/heap.h"
#include "catalog/indexing.h"
#include "catalog/namespace.h"
#include "catalog/objectaccess.h"
#include "catalog/objectaddress.h"
+#include "catalog/pg_am_d.h"
#include "catalog/pg_authid_d.h"
#include "catalog/pg_database_d.h"
+#include "catalog/pg_namespace.h"
#include "catalog/pg_subscription.h"
#include "catalog/pg_subscription_rel.h"
#include "catalog/pg_type.h"
+#include "commands/comment.h"
#include "commands/defrem.h"
#include "commands/event_trigger.h"
#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "miscadmin.h"
#include "nodes/makefuncs.h"
#include "pgstat.h"
@@ -51,6 +57,7 @@
#include "utils/lsyscache.h"
#include "utils/memutils.h"
#include "utils/pg_lsn.h"
+#include "utils/regproc.h"
#include "utils/syscache.h"
/*
@@ -75,6 +82,7 @@
#define SUBOPT_MAX_RETENTION_DURATION 0x00008000
#define SUBOPT_LSN 0x00010000
#define SUBOPT_ORIGIN 0x00020000
+#define SUBOPT_CONFLICT_LOG_DEST 0x00040000
/* check if the 'val' has 'bits' set */
#define IsSet(val, bits) (((val) & (bits)) == (bits))
@@ -103,6 +111,7 @@ typedef struct SubOpts
bool retaindeadtuples;
int32 maxretention;
char *origin;
+ ConflictLogDest logdest;
XLogRecPtr lsn;
} SubOpts;
@@ -135,7 +144,7 @@ static List *merge_publications(List *oldpublist, List *newpublist, bool addpub,
static void ReportSlotConnectionError(List *rstates, Oid subid, char *slotname, char *err);
static void CheckAlterSubOption(Subscription *sub, const char *option,
bool slot_needs_update, bool isTopLevel);
-
+static Oid create_conflict_log_table(Oid subid, char *subname);
/*
* Common option parsing function for CREATE and ALTER SUBSCRIPTION commands.
@@ -191,6 +200,8 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->maxretention = 0;
if (IsSet(supported_opts, SUBOPT_ORIGIN))
opts->origin = pstrdup(LOGICALREP_ORIGIN_ANY);
+ if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DEST))
+ opts->logdest = CONFLICT_LOG_DEST_LOG;
/* Parse options */
foreach(lc, stmt_options)
@@ -402,6 +413,18 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->specified_opts |= SUBOPT_LSN;
opts->lsn = lsn;
}
+ else if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DEST) &&
+ strcmp(defel->defname, "conflict_log_destination") == 0)
+ {
+ char *val;
+
+ if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_LOG_DEST))
+ errorConflictingDefElem(defel, pstate);
+
+ val = defGetString(defel);
+ opts->logdest = GetLogDestination(val);
+ opts->specified_opts |= SUBOPT_CONFLICT_LOG_DEST;
+ }
else
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
@@ -599,6 +622,7 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bits32 supported_opts;
SubOpts opts = {0};
AclResult aclresult;
+ Oid logrelid = InvalidOid;
/*
* Parse and check options.
@@ -612,7 +636,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
SUBOPT_DISABLE_ON_ERR | SUBOPT_PASSWORD_REQUIRED |
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
- SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN);
+ SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_DEST);
parse_subscription_options(pstate, stmt->options, supported_opts, &opts);
/*
@@ -747,6 +772,18 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
values[Anum_pg_subscription_suborigin - 1] =
CStringGetTextDatum(opts.origin);
+ /* Always set the destination, default will be 'log'. */
+ values[Anum_pg_subscription_subconflictlogdest - 1] =
+ CStringGetTextDatum(ConflictLogDestNames[opts.logdest]);
+
+ /* If logging to a table is required, physically create the table. */
+ if (IsSet(opts.logdest, CONFLICT_LOG_DEST_TABLE))
+ logrelid = create_conflict_log_table(subid, stmt->subname);
+
+ /* Store table OID in the catalog. */
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(logrelid);
+
tup = heap_form_tuple(RelationGetDescr(rel), values, nulls);
/* Insert tuple into catalog. */
@@ -1410,7 +1447,8 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
SUBOPT_MAX_RETENTION_DURATION |
- SUBOPT_ORIGIN);
+ SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_DEST);
parse_subscription_options(pstate, stmt->options,
supported_opts, &opts);
@@ -1665,6 +1703,63 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
origin = opts.origin;
}
+ if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_LOG_DEST))
+ {
+ ConflictLogDest old_dest =
+ GetLogDestination(sub->conflictlogdest);
+
+ if (opts.logdest != old_dest)
+ {
+ bool want_table =
+ IsSet(opts.logdest, CONFLICT_LOG_DEST_TABLE);
+ bool has_oldtable =
+ IsSet(old_dest, CONFLICT_LOG_DEST_TABLE);
+
+ values[Anum_pg_subscription_subconflictlogdest - 1] =
+ CStringGetTextDatum(ConflictLogDestNames[opts.logdest]);
+ replaces[Anum_pg_subscription_subconflictlogdest - 1] = true;
+
+ if (want_table && !has_oldtable)
+ {
+ Oid relid;
+
+ relid = create_conflict_log_table(subid, sub->name);
+
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(relid);
+ replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
+ true;
+ }
+ else if (!want_table && has_oldtable)
+ {
+ ObjectAddress object;
+
+ /*
+ * Conflict log tables are recorded as internal
+ * dependencies of the subscription. Drop the
+ * table if it is not required anymore to avoid
+ * stale or orphaned relations.
+ *
+ * XXX: At present, only conflict log tables are
+ * managed this way. In future if we introduce
+ * additional internal dependencies, we may need
+ * a targeted deletion to avoid deletion of any
+ * other objects.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId,
+ subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(InvalidOid);
+ replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
+ true;
+ }
+ }
+ }
+
update_tuple = true;
break;
}
@@ -2027,6 +2122,7 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
Form_pg_subscription form;
List *rstates;
bool must_use_password;
+ ObjectAddress object;
/*
* The launcher may concurrently start a new worker for this subscription.
@@ -2184,6 +2280,19 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
/* Clean up dependencies */
deleteSharedDependencyRecordsFor(SubscriptionRelationId, subid, 0);
+ /*
+ * Conflict log tables are recorded as internal dependencies of the
+ * subscription. We must drop the dependent objects before the
+ * subscription itself is removed. By using
+ * PERFORM_DELETION_SKIP_ORIGINAL, we ensure that only the conflict log
+ * table is reaped while the subscription remains for the final deletion
+ * step.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId, subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
/* Remove any associated relation synchronization states. */
RemoveSubscriptionRel(subid, InvalidOid);
@@ -3190,3 +3299,127 @@ defGetStreamingMode(DefElem *def)
def->defname)));
return LOGICALREP_STREAM_OFF; /* keep compiler quiet */
}
+
+/*
+ * Builds the TupleDesc for the conflict log table.
+ */
+static TupleDesc
+create_conflict_log_table_tupdesc(void)
+{
+ TupleDesc tupdesc;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_CONFLICT_ATTR_NUM);
+
+ for (int i = 0; i < MAX_CONFLICT_ATTR_NUM; i++)
+ {
+ Oid type_oid = ConflictLogSchema[i].atttypid;
+
+ /*
+ * Special handling for the JSON array type for proper
+ * TupleDescInitEntry call.
+ */
+ if (type_oid == JSONARRAYOID)
+ type_oid = get_array_type(JSONOID);
+
+ TupleDescInitEntry(tupdesc, i + 1,
+ ConflictLogSchema[i].attname,
+ type_oid,
+ -1, 0);
+ }
+
+ return BlessTupleDesc(tupdesc);
+}
+
+/*
+ * Create a structured conflict log table for a subscription.
+ *
+ * The table is created within the system-managed 'pg_conflict' namespace.
+ * The table name is generated automatically using the subscription's OID
+ * (e.g., "pg_conflict_<subid>") to ensure uniqueness within the cluster and
+ * to avoid collisions during subscription renames.
+ */
+static Oid
+create_conflict_log_table(Oid subid, char *subname)
+{
+ TupleDesc tupdesc;
+ Oid relid;
+ ObjectAddress myself;
+ ObjectAddress subaddr;
+ char relname[NAMEDATALEN];
+
+ snprintf(relname, NAMEDATALEN, "pg_conflict_%u", subid);
+
+ /* There can not be an existing table with the same name. */
+ Assert(!OidIsValid(get_relname_relid(relname, PG_CONFLICT_NAMESPACE)));
+
+ /* Build the tuple descriptor for the new table. */
+ tupdesc = create_conflict_log_table_tupdesc();
+
+ /* Create conflict log table. */
+ relid = heap_create_with_catalog(relname,
+ PG_CONFLICT_NAMESPACE,
+ 0, /* tablespace */
+ InvalidOid, /* relid */
+ InvalidOid, /* reltypeid */
+ InvalidOid, /* reloftypeid */
+ GetUserId(),
+ HEAP_TABLE_AM_OID,
+ tupdesc,
+ NIL,
+ RELKIND_RELATION,
+ RELPERSISTENCE_PERMANENT,
+ false, /* shared_relation */
+ false, /* mapped_relation */
+ ONCOMMIT_NOOP,
+ (Datum) 0, /* reloptions */
+ false, /* use_user_acl */
+ true, /* allow_system_table_mods */
+ true, /* is_internal */
+ InvalidOid, /* relrewrite */
+ NULL); /* typaddress */
+
+ /*
+ * Establish an internal dependency between the conflict log table and
+ * the subscription.
+ *
+ * We use DEPENDENCY_INTERNAL to signify that the table's lifecycle is
+ * strictly tied to the subscription, similar to how a TOAST table relates
+ * to its main table or a sequence relates to an identity column.
+ *
+ * This ensures the conflict log table is automatically reaped during a
+ * DROP SUBSCRIPTION via performDeletion().
+ */
+ ObjectAddressSet(myself, RelationRelationId, relid);
+ ObjectAddressSet(subaddr, SubscriptionRelationId, subid);
+ recordDependencyOn(&myself, &subaddr, DEPENDENCY_INTERNAL);
+
+ /* Release tuple descriptor memory. */
+ FreeTupleDesc(tupdesc);
+
+ return relid;
+}
+
+/*
+ * GetLogDestination
+ *
+ * Convert string to enum by comparing against standardized labels.
+ */
+ConflictLogDest
+GetLogDestination(const char *dest)
+{
+ /* Empty string or NULL defaults to LOG. */
+ if (dest == NULL || dest[0] == '\0' || pg_strcasecmp(dest, "log") == 0)
+ return CONFLICT_LOG_DEST_LOG;
+
+ if (pg_strcasecmp(dest, "table") == 0)
+ return CONFLICT_LOG_DEST_TABLE;
+
+ if (pg_strcasecmp(dest, "all") == 0)
+ return CONFLICT_LOG_DEST_ALL;
+
+ /* Unrecognized string. */
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("unrecognized conflict_log_destination value: \"%s\"", dest),
+ errhint("Valid values are \"log\", \"table\", and \"all\".")));
+}
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index 3584c4e1428..20f08e548ba 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -6806,7 +6806,7 @@ describeSubscriptions(const char *pattern, bool verbose)
printQueryOpt myopt = pset.popt;
static const bool translate_columns[] = {false, false, false, false,
false, false, false, false, false, false, false, false, false, false,
- false, false, false, false};
+ false, false, false, false, false};
if (pset.sversion < 100000)
{
@@ -6900,15 +6900,22 @@ describeSubscriptions(const char *pattern, bool verbose)
appendPQExpBuffer(&buf,
", subskiplsn AS \"%s\"\n",
gettext_noop("Skip LSN"));
+
+ /* Conflict log destination is supported in v19 and higher */
+ if (pset.sversion >= 190000)
+ {
+ appendPQExpBuffer(&buf,
+ ", subconflictlogdest AS \"%s\"\n",
+ gettext_noop("Conflict log destination"));
+ }
}
/* Only display subscriptions in current database. */
- appendPQExpBufferStr(&buf,
- "FROM pg_catalog.pg_subscription\n"
- "WHERE subdbid = (SELECT oid\n"
- " FROM pg_catalog.pg_database\n"
- " WHERE datname = pg_catalog.current_database())");
-
+ appendPQExpBuffer(&buf,
+ "FROM pg_catalog.pg_subscription "
+ "WHERE subdbid = (SELECT oid\n"
+ " FROM pg_catalog.pg_database\n"
+ " WHERE datname = pg_catalog.current_database())");
if (!validateSQLNamePattern(&buf, pattern, true, false,
NULL, "subname", NULL,
NULL,
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 8b91bc00062..12eee8a0d43 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -2344,8 +2344,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("(", "PUBLICATION");
/* ALTER SUBSCRIPTION <name> SET ( */
else if (Matches("ALTER", "SUBSCRIPTION", MatchAny, MatchAnyN, "SET", "("))
- COMPLETE_WITH("binary", "disable_on_error", "failover",
- "max_retention_duration", "origin",
+ COMPLETE_WITH("binary", "conflict_log_destination", "disable_on_error",
+ "failover", "max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
"synchronous_commit", "two_phase");
@@ -3860,8 +3860,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("WITH (");
/* Complete "CREATE SUBSCRIPTION <name> ... WITH ( <opt>" */
else if (Matches("CREATE", "SUBSCRIPTION", MatchAnyN, "WITH", "("))
- COMPLETE_WITH("binary", "connect", "copy_data", "create_slot",
- "disable_on_error", "enabled", "failover",
+ COMPLETE_WITH("binary", "conflict_log_destination", "connect", "copy_data",
+ "create_slot", "disable_on_error", "enabled", "failover",
"max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
diff --git a/src/include/catalog/catalog.h b/src/include/catalog/catalog.h
index a9d6e8ea986..8193229f2e2 100644
--- a/src/include/catalog/catalog.h
+++ b/src/include/catalog/catalog.h
@@ -25,6 +25,7 @@ extern bool IsInplaceUpdateRelation(Relation relation);
extern bool IsSystemClass(Oid relid, Form_pg_class reltuple);
extern bool IsToastClass(Form_pg_class reltuple);
+extern bool IsConflictClass(Form_pg_class reltuple);
extern bool IsCatalogRelationOid(Oid relid);
extern bool IsCatalogTextUniqueIndexOid(Oid relid);
@@ -32,6 +33,7 @@ extern bool IsInplaceUpdateOid(Oid relid);
extern bool IsCatalogNamespace(Oid namespaceId);
extern bool IsToastNamespace(Oid namespaceId);
+extern bool IsConflictNamespace(Oid namespaceId);
extern bool IsReservedName(const char *name);
diff --git a/src/include/catalog/pg_namespace.dat b/src/include/catalog/pg_namespace.dat
index 3075e142c73..b45cb9383a8 100644
--- a/src/include/catalog/pg_namespace.dat
+++ b/src/include/catalog/pg_namespace.dat
@@ -18,6 +18,9 @@
{ oid => '99', oid_symbol => 'PG_TOAST_NAMESPACE',
descr => 'reserved schema for TOAST tables',
nspname => 'pg_toast', nspacl => '_null_' },
+{ oid => '1382', oid_symbol => 'PG_CONFLICT_NAMESPACE',
+ descr => 'reserved schema for subscription-specific conflict log tables',
+ nspname => 'pg_conflict', nspacl => '_null_' },
# update dumpNamespace() if changing this descr
{ oid => '2200', oid_symbol => 'PG_PUBLIC_NAMESPACE',
descr => 'standard public schema',
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index f3571d2bfcf..4aa29ea15d4 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -90,6 +90,7 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
* exceeded max_retention_duration, when
* defined */
+ Oid subconflictlogrelid; /* Relid of the conflict log table. */
#ifdef CATALOG_VARLEN /* variable-length fields start here */
/* Connection string to the publisher */
text subconninfo BKI_FORCE_NOT_NULL;
@@ -103,6 +104,14 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
/* List of publications subscribed to */
text subpublications[1] BKI_FORCE_NOT_NULL;
+ /*
+ * Strategy for logging replication conflicts:
+ * 'log' - server log only,
+ * 'table' - internal table only,
+ * 'all' - both log and table.
+ */
+ text subconflictlogdest;
+
/* Only publish data originating from the specified origin */
text suborigin BKI_DEFAULT(LOGICALREP_ORIGIN_ANY);
#endif
@@ -152,12 +161,14 @@ typedef struct Subscription
* and the retention duration has not
* exceeded max_retention_duration, when
* defined */
+ Oid conflictlogrelid; /* conflict log table Oid */
char *conninfo; /* Connection string to the publisher */
char *slotname; /* Name of the replication slot */
char *synccommit; /* Synchronous commit setting for worker */
List *publications; /* List of publication names to subscribe to */
char *origin; /* Only publish data originating from the
* specified origin */
+ char *conflictlogdest; /* Conflict log destination */
} Subscription;
#ifdef EXPOSE_TO_CLIENT_CODE
diff --git a/src/include/commands/subscriptioncmds.h b/src/include/commands/subscriptioncmds.h
index 63504232a14..a895127f8fe 100644
--- a/src/include/commands/subscriptioncmds.h
+++ b/src/include/commands/subscriptioncmds.h
@@ -17,6 +17,7 @@
#include "catalog/objectaddress.h"
#include "parser/parse_node.h"
+#include "replication/conflict.h"
extern ObjectAddress CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bool isTopLevel);
@@ -36,4 +37,6 @@ extern void CheckSubDeadTupleRetention(bool check_guc, bool sub_disabled,
bool retention_active,
bool max_retention_set);
+extern ConflictLogDest GetLogDestination(const char *dest);
+
#endif /* SUBSCRIPTIONCMDS_H */
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index d538274637f..ecdfacd4f00 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -10,6 +10,7 @@
#define CONFLICT_H
#include "access/xlogdefs.h"
+#include "catalog/pg_type.h"
#include "nodes/pg_list.h"
#include "utils/timestamp.h"
@@ -79,6 +80,60 @@ typedef struct ConflictTupleInfo
* conflicting local row occurred */
} ConflictTupleInfo;
+/*
+ * Conflict log destination types.
+ *
+ * These values are defined as bitmask flags to allow for multiple simultaneous
+ * logging destinations (e.g., logging to both system logs and a table).
+ * Internally, we use these for bitwise comparisons (IsSet), but the string
+ * representation is stored in pg_subscription.subconflictlogdest.
+ */
+typedef enum ConflictLogDest
+{
+ /* Log conflicts to the server logs */
+ CONFLICT_LOG_DEST_LOG = 1 << 0, /* 0x01 */
+
+ /* Log conflicts to an internally managed table */
+ CONFLICT_LOG_DEST_TABLE = 1 << 1, /* 0x02 */
+
+ /* Convenience flag for all supported destinations */
+ CONFLICT_LOG_DEST_ALL = (CONFLICT_LOG_DEST_LOG | CONFLICT_LOG_DEST_TABLE)
+} ConflictLogDest;
+
+/*
+ * Array mapping for converting internal enum to string.
+ */
+static const char *const ConflictLogDestNames[] = {
+ [CONFLICT_LOG_DEST_LOG] = "log",
+ [CONFLICT_LOG_DEST_TABLE] = "table",
+ [CONFLICT_LOG_DEST_ALL] = "all"
+};
+
+/* Structure to hold metadata for one column of the conflict log table */
+typedef struct ConflictLogColumnDef
+{
+ const char *attname; /* Column name */
+ Oid atttypid; /* Data type OID */
+} ConflictLogColumnDef;
+
+/* The single source of truth for the conflict log table schema */
+static const ConflictLogColumnDef ConflictLogSchema[] =
+{
+ { .attname = "relid", .atttypid = OIDOID },
+ { .attname = "schemaname", .atttypid = TEXTOID },
+ { .attname = "relname", .atttypid = TEXTOID },
+ { .attname = "conflict_type", .atttypid = TEXTOID },
+ { .attname = "remote_xid", .atttypid = XIDOID },
+ { .attname = "remote_commit_lsn",.atttypid = LSNOID },
+ { .attname = "remote_commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "remote_origin", .atttypid = TEXTOID },
+ { .attname = "replica_identity", .atttypid = JSONOID },
+ { .attname = "remote_tuple", .atttypid = JSONOID },
+ { .attname = "local_conflicts", .atttypid = JSONARRAYOID }
+};
+
+#define MAX_CONFLICT_ATTR_NUM lengthof(ConflictLogSchema)
+
extern bool GetTupleTransactionInfo(TupleTableSlot *localslot,
TransactionId *xmin,
RepOriginId *localorigin,
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index b3eccd8afe3..a71bb87f0e2 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -116,18 +116,18 @@ CREATE SUBSCRIPTION regress_testsub4 CONNECTION 'dbname=regress_doesnotexist' PU
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub4 SET (origin = any);
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub3;
@@ -145,10 +145,10 @@ ALTER SUBSCRIPTION regress_testsub CONNECTION 'foobar';
ERROR: invalid connection string syntax: missing "=" after "foobar" in connection info string
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET PUBLICATION testpub2, testpub3 WITH (refresh = false);
@@ -157,10 +157,10 @@ ALTER SUBSCRIPTION regress_testsub SET (slot_name = 'newname');
ALTER SUBSCRIPTION regress_testsub SET (password_required = false);
ALTER SUBSCRIPTION regress_testsub SET (run_as_owner = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (password_required = true);
@@ -176,10 +176,10 @@ ERROR: unrecognized subscription parameter: "create_slot"
-- ok
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/12345');
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345 | log
(1 row)
-- ok - with lsn = NONE
@@ -188,10 +188,10 @@ ALTER SUBSCRIPTION regress_testsub SKIP (lsn = NONE);
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/0');
ERROR: invalid WAL location (LSN): 0/0
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
BEGIN;
@@ -223,10 +223,10 @@ ALTER SUBSCRIPTION regress_testsub_foo SET (synchronous_commit = foobar);
ERROR: invalid value for parameter "synchronous_commit": "foobar"
HINT: Available values: local, remote_write, remote_apply, on, off.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
----------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+---------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------
+ regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000 | log
(1 row)
-- rename back to keep the rest simple
@@ -255,19 +255,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (binary = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -279,27 +279,27 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = parallel);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- fail - publication already exists
@@ -314,10 +314,10 @@ ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refr
ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refresh = false);
ERROR: publication "testpub1" is already in subscription "regress_testsub"
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- fail - publication used more than once
@@ -332,10 +332,10 @@ ERROR: publication "testpub3" is not in subscription "regress_testsub"
-- ok - delete publications
ALTER SUBSCRIPTION regress_testsub DROP PUBLICATION testpub1, testpub2 WITH (refresh = false);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -371,19 +371,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- we can alter streaming when two_phase enabled
ALTER SUBSCRIPTION regress_testsub SET (streaming = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -393,10 +393,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -409,18 +409,18 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (disable_on_error = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -433,10 +433,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -450,19 +450,19 @@ NOTICE: max_retention_duration is ineffective when retain_dead_tuples is disabl
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
-- ok
ALTER SUBSCRIPTION regress_testsub SET (max_retention_duration = 0);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -517,6 +517,251 @@ COMMIT;
-- ok, owning it is enough for this stuff
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG DESTINATION TESTS
+--
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+-- fail - unrecognized parameter value
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
+ERROR: unrecognized conflict_log_destination value: "invalid"
+HINT: Valid values are "log", "table", and "all".
+-- verify subconflictlogdest is 'log' and relid is 0 (InvalidOid) for default case
+CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
+ subname | subconflictlogdest | subconflictlogrelid
+------------------------------+--------------------+---------------------
+ regress_conflict_log_default | log | 0
+(1 row)
+
+-- verify empty string defaults to 'log'
+CREATE SUBSCRIPTION regress_conflict_empty_str CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = '');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
+ subname | subconflictlogdest | subconflictlogrelid
+----------------------------+--------------------+---------------------
+ regress_conflict_empty_str | log | 0
+(1 row)
+
+-- this should generate an internal table named pg_conflict_$subid$
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- check metadata in pg_subscription: destination should be 'table' and relid valid
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname | subconflictlogdest | has_relid
+------------------------+--------------------+-----------
+ regress_conflict_test1 | table | t
+(1 row)
+
+-- verify the physical table exists, its OID matches subconflictlogrelid,
+-- and it is located in the 'pg_conflict' namespace.
+SELECT n.nspname, (c.oid = s.subconflictlogrelid) AS "oid_matches"
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+JOIN pg_namespace n ON c.relnamespace = n.oid
+WHERE s.subname = 'regress_conflict_test1';
+ nspname | oid_matches
+-------------+-------------
+ pg_conflict | t
+(1 row)
+
+-- check if the internal table has the correct schema
+SELECT a.attnum, a.attname
+FROM pg_attribute a
+JOIN pg_class c ON a.attrelid = c.oid
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0
+ ORDER BY a.attnum;
+ attnum | attname
+--------+-------------------
+ 1 | relid
+ 2 | schemaname
+ 3 | relname
+ 4 | conflict_type
+ 5 | remote_xid
+ 6 | remote_commit_lsn
+ 7 | remote_commit_ts
+ 8 | remote_origin
+ 9 | replica_identity
+ 10 | remote_tuple
+ 11 | local_conflicts
+(11 rows)
+
+--
+-- ALTER SUBSCRIPTION - conflict_log_destination state transitions
+--
+-- These tests verify the transition logic between different logging
+-- destinations, ensuring internal tables are created or dropped as expected.
+--
+-- transition from 'log' to 'all'
+-- a new internal conflict log table should be created
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
+-- verify metadata after ALTER (destination should be 'all')
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogdest | has_relid
+------------------------+--------------------+-----------
+ regress_conflict_test2 | all | t
+(1 row)
+
+-- transition from 'all' to 'table'
+-- should NOT drop the table, only change destination string
+SELECT subconflictlogrelid AS old_relid FROM pg_subscription WHERE subname = 'regress_conflict_test2' \gset
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'table');
+SELECT subconflictlogdest, subconflictlogrelid = :old_relid AS relid_unchanged
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subconflictlogdest | relid_unchanged
+--------------------+-----------------
+ table | t
+(1 row)
+
+-- transition from 'table' to 'log'
+-- should drop the table and clear relid
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log');
+SELECT subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subconflictlogdest | subconflictlogrelid
+--------------------+---------------------
+ log | 0
+(1 row)
+
+-- verify the physical table is gone
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test2';
+ count
+-------
+ 0
+(1 row)
+
+--
+-- PUBLICATION: Verify internal tables are not publishable
+--
+-- pg_relation_is_publishable should return false for internal conflict log
+-- tables to prevent them from being accidentally included in publications.
+--
+SELECT n.nspname, pg_relation_is_publishable(c.oid)
+FROM pg_class c
+JOIN pg_namespace n ON c.relnamespace = n.oid
+JOIN pg_subscription s ON s.subconflictlogrelid = c.oid
+WHERE s.subname = 'regress_conflict_test1';
+ nspname | pg_relation_is_publishable
+-------------+----------------------------
+ pg_conflict | f
+(1 row)
+
+--
+-- Table Protection and Lifecycle Management
+--
+-- These tests verify that:
+-- Manual DROP TABLE is disallowed.
+-- DROP SUBSCRIPTION automatically reaps the table.
+--
+-- re-enable table logging for verification
+ALTER SUBSCRIPTION regress_conflict_test1 SET (conflict_log_destination = 'table');
+-- We use a DO block with dynamic SQL because the internal conflict log table
+-- name contains the subscription OID, which is non-deterministic. This
+-- approach allows us to attempt the DROP and capture the expected error
+-- without hard-coding a specific OID in the expected output.
+-- fail - drop table not allowed due to internal dependency
+DO $$
+BEGIN
+ EXECUTE 'DROP TABLE ' || (SELECT 'pg_conflict.pg_conflict_' || oid FROM pg_subscription WHERE subname = 'regress_conflict_test1');
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege';
+END $$;
+NOTICE: captured expected error: insufficient_privilege
+-- CLEANUP: DROP SUBSCRIPTION reaps the table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+-- Verify the table OID for reap check
+SELECT 'pg_conflict_' || oid AS internal_tablename FROM pg_subscription WHERE subname = 'regress_conflict_test1' \gset
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the internal table was reaped via dependency
+SELECT to_regclass(:'internal_tablename');
+ to_regclass
+-------------
+
+(1 row)
+
+--
+-- Additional Namespace and Table Protection Tests
+--
+-- Setup: Ensure we have a subscription with a conflict log table
+CREATE SUBSCRIPTION regress_conflict_protection_test CONNECTION 'dbname=regress_doesnotexist'
+ PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- Trying to ALTER the internal conflict log table
+-- This should fail because the table is system-managed.
+-- As mentioned in previous test cases, we use a DO block to hide dynamic OIDs.
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ RAISE NOTICE 'Attempting ALTER TABLE on internal conflict table';
+ EXECUTE 'ALTER TABLE ' || tab_name || ' ADD COLUMN extra_info text';
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during ALTER';
+END $$;
+NOTICE: Attempting ALTER TABLE on internal conflict table
+NOTICE: captured expected error: insufficient_privilege during ALTER
+-- Trying to TRUNCATE the internal conflict log table
+-- This should be allowed.
+-- As mentioned in previous test cases, we use a DO block to hide dynamic OIDs.
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ RAISE NOTICE 'Attempting TRUNCATE on internal conflict table';
+ EXECUTE 'TRUNCATE ' || tab_name;
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during TRUNCATE';
+END $$;
+NOTICE: Attempting TRUNCATE on internal conflict table
+NOTICE: captured expected error: insufficient_privilege during TRUNCATE
+-- Trying to create a new table manually in the pg_conflict namespace
+-- This should fail as the namespace is reserved for conflict logs tables.
+CREATE TABLE pg_conflict.manual_table (id int);
+ERROR: permission denied to create "pg_conflict.manual_table"
+DETAIL: System catalog modifications are currently disallowed.
+-- Moving an existing table into the pg_conflict namespace
+-- Users should not be able to move their own tables within this namespace.
+CREATE TABLE public.test_move (id int);
+ALTER TABLE public.test_move SET SCHEMA pg_conflict;
+ERROR: cannot move objects into or out of CONFLICT schema
+DROP TABLE public.test_move;
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_log_default DISABLE;
+ALTER SUBSCRIPTION regress_conflict_log_default SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_log_default;
+ALTER SUBSCRIPTION regress_conflict_empty_str DISABLE;
+ALTER SUBSCRIPTION regress_conflict_empty_str SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_empty_str;
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+ALTER SUBSCRIPTION regress_conflict_protection_test DISABLE;
+ALTER SUBSCRIPTION regress_conflict_protection_test SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_protection_test;
RESET SESSION AUTHORIZATION;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index ef0c298d2df..01f2ea063ef 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -365,6 +365,200 @@ COMMIT;
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG DESTINATION TESTS
+--
+
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+
+-- fail - unrecognized parameter value
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
+
+-- verify subconflictlogdest is 'log' and relid is 0 (InvalidOid) for default case
+CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
+
+-- verify empty string defaults to 'log'
+CREATE SUBSCRIPTION regress_conflict_empty_str CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = '');
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
+
+-- this should generate an internal table named pg_conflict_$subid$
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+
+-- check metadata in pg_subscription: destination should be 'table' and relid valid
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- verify the physical table exists, its OID matches subconflictlogrelid,
+-- and it is located in the 'pg_conflict' namespace.
+SELECT n.nspname, (c.oid = s.subconflictlogrelid) AS "oid_matches"
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+JOIN pg_namespace n ON c.relnamespace = n.oid
+WHERE s.subname = 'regress_conflict_test1';
+
+-- check if the internal table has the correct schema
+SELECT a.attnum, a.attname
+FROM pg_attribute a
+JOIN pg_class c ON a.attrelid = c.oid
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0
+ ORDER BY a.attnum;
+
+--
+-- ALTER SUBSCRIPTION - conflict_log_destination state transitions
+--
+-- These tests verify the transition logic between different logging
+-- destinations, ensuring internal tables are created or dropped as expected.
+--
+
+-- transition from 'log' to 'all'
+-- a new internal conflict log table should be created
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
+
+-- verify metadata after ALTER (destination should be 'all')
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'all' to 'table'
+-- should NOT drop the table, only change destination string
+SELECT subconflictlogrelid AS old_relid FROM pg_subscription WHERE subname = 'regress_conflict_test2' \gset
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'table');
+SELECT subconflictlogdest, subconflictlogrelid = :old_relid AS relid_unchanged
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'table' to 'log'
+-- should drop the table and clear relid
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log');
+SELECT subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- verify the physical table is gone
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test2';
+
+--
+-- PUBLICATION: Verify internal tables are not publishable
+--
+-- pg_relation_is_publishable should return false for internal conflict log
+-- tables to prevent them from being accidentally included in publications.
+--
+SELECT n.nspname, pg_relation_is_publishable(c.oid)
+FROM pg_class c
+JOIN pg_namespace n ON c.relnamespace = n.oid
+JOIN pg_subscription s ON s.subconflictlogrelid = c.oid
+WHERE s.subname = 'regress_conflict_test1';
+
+--
+-- Table Protection and Lifecycle Management
+--
+-- These tests verify that:
+-- Manual DROP TABLE is disallowed.
+-- DROP SUBSCRIPTION automatically reaps the table.
+--
+-- re-enable table logging for verification
+ALTER SUBSCRIPTION regress_conflict_test1 SET (conflict_log_destination = 'table');
+
+-- We use a DO block with dynamic SQL because the internal conflict log table
+-- name contains the subscription OID, which is non-deterministic. This
+-- approach allows us to attempt the DROP and capture the expected error
+-- without hard-coding a specific OID in the expected output.
+
+-- fail - drop table not allowed due to internal dependency
+DO $$
+BEGIN
+ EXECUTE 'DROP TABLE ' || (SELECT 'pg_conflict.pg_conflict_' || oid FROM pg_subscription WHERE subname = 'regress_conflict_test1');
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege';
+END $$;
+
+-- CLEANUP: DROP SUBSCRIPTION reaps the table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+
+-- Verify the table OID for reap check
+SELECT 'pg_conflict_' || oid AS internal_tablename FROM pg_subscription WHERE subname = 'regress_conflict_test1' \gset
+
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the internal table was reaped via dependency
+SELECT to_regclass(:'internal_tablename');
+
+--
+-- Additional Namespace and Table Protection Tests
+--
+
+-- Setup: Ensure we have a subscription with a conflict log table
+CREATE SUBSCRIPTION regress_conflict_protection_test CONNECTION 'dbname=regress_doesnotexist'
+ PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+
+-- Trying to ALTER the internal conflict log table
+-- This should fail because the table is system-managed.
+-- As mentioned in previous test cases, we use a DO block to hide dynamic OIDs.
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ RAISE NOTICE 'Attempting ALTER TABLE on internal conflict table';
+ EXECUTE 'ALTER TABLE ' || tab_name || ' ADD COLUMN extra_info text';
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during ALTER';
+END $$;
+
+-- Trying to TRUNCATE the internal conflict log table
+-- This should be allowed.
+-- As mentioned in previous test cases, we use a DO block to hide dynamic OIDs.
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ RAISE NOTICE 'Attempting TRUNCATE on internal conflict table';
+ EXECUTE 'TRUNCATE ' || tab_name;
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during TRUNCATE';
+END $$;
+
+-- Trying to create a new table manually in the pg_conflict namespace
+-- This should fail as the namespace is reserved for conflict logs tables.
+CREATE TABLE pg_conflict.manual_table (id int);
+
+-- Moving an existing table into the pg_conflict namespace
+-- Users should not be able to move their own tables within this namespace.
+CREATE TABLE public.test_move (id int);
+ALTER TABLE public.test_move SET SCHEMA pg_conflict;
+DROP TABLE public.test_move;
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_log_default DISABLE;
+ALTER SUBSCRIPTION regress_conflict_log_default SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_log_default;
+
+ALTER SUBSCRIPTION regress_conflict_empty_str DISABLE;
+ALTER SUBSCRIPTION regress_conflict_empty_str SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_empty_str;
+
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+
+ALTER SUBSCRIPTION regress_conflict_protection_test DISABLE;
+ALTER SUBSCRIPTION regress_conflict_protection_test SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_protection_test;
+
+
RESET SESSION AUTHORIZATION;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 09e7f1d420e..4b46c669919 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -500,6 +500,8 @@ ConditionVariableMinimallyPadded
ConditionalStack
ConfigData
ConfigVariable
+ConflictLogColumnDef
+ConflictLogDest
ConflictTupleInfo
ConflictType
ConnCacheEntry
--
2.49.0
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-13 10:28 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
4 siblings, 1 reply; 912+ messages in thread
From: shveta malik @ 2026-01-13 10:28 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Peter Smith <smithpb2250@gmail.com>; vignesh C <vignesh21@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Sat, Jan 10, 2026 at 6:45 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
>>
> Here is the updated patch
>
Thanks, I will review the code. Few concerns while experimenting with 001 alone:
1)
I am able to insert and update rows in pg_conflict.pg_conflict_16394.
It should be restricted.
2)
I think we need to block 'SELECT for UPDATE' and similar operations on
CLT. Currently it is allowed on CLT.
See this:
postgres=# SELECT * FROM pg_toast.pg_toast_3466 for UPDATE;
ERROR: cannot lock rows in TOAST relation "pg_toast_3466"
postgres=# SELECT * FROM pg_conflict.pg_conflict_16394 for UPDATE;
....
(1 row)
3)
We currently skip ANALYZE on TOAST tables, but I’m not sure whether
the same restriction should apply to CLT. Since users are expected to
query CLT frequently, collecting statistics could be beneficial. Even
though there are currently no indexes or other structures to enable
more optimal plans, having statistics should not harm. Thoughts?
postgres=# analyze pg_toast.pg_toast_16399;
WARNING: skipping "pg_toast_16399" --- cannot analyze non-tables or
special system tables
postgres=# analyze pg_conflict.pg_conflict_16394;
ANALYZE
4)
It will be good to show 'Conflict Log Table:' in \dRs command.
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-13 10:39 Dilip Kumar <dilipbalaut@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 2 replies; 912+ messages in thread
From: Dilip Kumar @ 2026-01-13 10:39 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Peter Smith <smithpb2250@gmail.com>; vignesh C <vignesh21@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, Jan 13, 2026 at 3:59 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> On Sat, Jan 10, 2026 at 6:45 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> >>
> > Here is the updated patch
> >
>
> Thanks, I will review the code. Few concerns while experimenting with 001 alone:
>
> 1)
> I am able to insert and update rows in pg_conflict.pg_conflict_16394.
> It should be restricted.
>
> 2)
> I think we need to block 'SELECT for UPDATE' and similar operations on
> CLT. Currently it is allowed on CLT.
> See this:
>
> postgres=# SELECT * FROM pg_toast.pg_toast_3466 for UPDATE;
> ERROR: cannot lock rows in TOAST relation "pg_toast_3466"
> postgres=# SELECT * FROM pg_conflict.pg_conflict_16394 for UPDATE;
> ....
I sent an analysis on this a few days ago but realized it only went to
Amit. Resending to the full list:
So the summary is, currently, all INSERT/UPDATE/DELETE operations are
blocked for TOAST tables for safety. However, I have allowed these
operations for the pg-conflict table. Unlike TOAST, the system does
not internally reference conflict data, so user manipulation doesn't
pose a safety risk to the system. If a user modifies or deletes this
data, they simply lose their logs without impacting system stability.
What are your thoughts on this approach?
>> I Wrote
> I have been exploring the enforcement of these restrictions.
> Currently, DML is validated via CheckValidResultRel(). If we do not
> explicitly check for the conflict log table in that function, DML
> operations, including DELETE, will be permitted. While I am not overly
> concerned about users attempting manual INSERT or UPDATE operations.
> Now for TRUNCATE, the truncate_check_rel() currently relies on
> IsSystemClass() to restrict truncations. Since the conflict namespace
> is included in IsSystemClass() to prevent unauthorized DDL, TRUNCATE
> is blocked by default. We could allow it by adding an exception in
> truncate_check_rel() (e.g., IsSystemClass() &&
> !IsConflictNamespace()), but I am unsure if this is necessary. Do we
> really need to permit TRUNCATE, or is allowing DELETE sufficient for
> user-driven cleanup?
>
>
> 3)
> We currently skip ANALYZE on TOAST tables, but I’m not sure whether
> the same restriction should apply to CLT. Since users are expected to
> query CLT frequently, collecting statistics could be beneficial. Even
> though there are currently no indexes or other structures to enable
> more optimal plans, having statistics should not harm. Thoughts?
>
> postgres=# analyze pg_toast.pg_toast_16399;
> WARNING: skipping "pg_toast_16399" --- cannot analyze non-tables or
> special system tables
>
> postgres=# analyze pg_conflict.pg_conflict_16394;
> ANALYZE
I think its good to ANALYZE CLT data because user logically never need
to query the toast data, but they would have to query the conflict
data.
> 4)
> It will be good to show 'Conflict Log Table:' in \dRs command.
Yeah, you raised this point earlier as well, somehow I am missing this
point from the last few versions. I will analyze this and change this
as a first comment in the next version.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-13 11:32 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: shveta malik @ 2026-01-13 11:32 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Peter Smith <smithpb2250@gmail.com>; vignesh C <vignesh21@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Tue, Jan 13, 2026 at 4:09 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Tue, Jan 13, 2026 at 3:59 PM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > On Sat, Jan 10, 2026 at 6:45 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > >>
> > > Here is the updated patch
> > >
> >
> > Thanks, I will review the code. Few concerns while experimenting with 001 alone:
> >
> > 1)
> > I am able to insert and update rows in pg_conflict.pg_conflict_16394.
> > It should be restricted.
> >
> > 2)
> > I think we need to block 'SELECT for UPDATE' and similar operations on
> > CLT. Currently it is allowed on CLT.
> > See this:
> >
> > postgres=# SELECT * FROM pg_toast.pg_toast_3466 for UPDATE;
> > ERROR: cannot lock rows in TOAST relation "pg_toast_3466"
> > postgres=# SELECT * FROM pg_conflict.pg_conflict_16394 for UPDATE;
> > ....
>
> I sent an analysis on this a few days ago but realized it only went to
> Amit. Resending to the full list:
>
> So the summary is, currently, all INSERT/UPDATE/DELETE operations are
> blocked for TOAST tables for safety. However, I have allowed these
> operations for the pg-conflict table. Unlike TOAST, the system does
> not internally reference conflict data, so user manipulation doesn't
> pose a safety risk to the system. If a user modifies or deletes this
> data, they simply lose their logs without impacting system stability.
> What are your thoughts on this approach?
>
I don’t see a strong reason for a user to manually perform INSERT or
UPDATE here. But on rethinking, I also agree that allowing it does no
harm. It simply gives the user flexibility to modify data they “own”,
i.e., data that the system does not internally reference. Personally,
I’m okay with leaving it unrestricted, but it will be good to document
it as a NOTE/CAUTION, stating that these DML operations are allowed
and it is the user’s responsibility to manage any changes they make
manually.
>
> >> I Wrote
> > I have been exploring the enforcement of these restrictions.
> > Currently, DML is validated via CheckValidResultRel(). If we do not
> > explicitly check for the conflict log table in that function, DML
> > operations, including DELETE, will be permitted. While I am not overly
> > concerned about users attempting manual INSERT or UPDATE operations.
>
> > Now for TRUNCATE, the truncate_check_rel() currently relies on
> > IsSystemClass() to restrict truncations. Since the conflict namespace
> > is included in IsSystemClass() to prevent unauthorized DDL, TRUNCATE
> > is blocked by default. We could allow it by adding an exception in
> > truncate_check_rel() (e.g., IsSystemClass() &&
> > !IsConflictNamespace()), but I am unsure if this is necessary. Do we
> > really need to permit TRUNCATE, or is allowing DELETE sufficient for
> > user-driven cleanup?
>
I am okay if we allow DELETE alone.
> >
> >
> > 3)
> > We currently skip ANALYZE on TOAST tables, but I’m not sure whether
> > the same restriction should apply to CLT. Since users are expected to
> > query CLT frequently, collecting statistics could be beneficial. Even
> > though there are currently no indexes or other structures to enable
> > more optimal plans, having statistics should not harm. Thoughts?
> >
> > postgres=# analyze pg_toast.pg_toast_16399;
> > WARNING: skipping "pg_toast_16399" --- cannot analyze non-tables or
> > special system tables
> >
> > postgres=# analyze pg_conflict.pg_conflict_16394;
> > ANALYZE
>
> I think its good to ANALYZE CLT data because user logically never need
> to query the toast data, but they would have to query the conflict
> data.
Right. Do you think we shall allow index creation as well on this
table? It may help if the table is huge. As an example, a user can
create an index on relid to query it faster. Currently it is not
allowed.
> > 4)
> > It will be good to show 'Conflict Log Table:' in \dRs command.
>
> Yeah, you raised this point earlier as well, somehow I am missing this
> point from the last few versions. I will analyze this and change this
> as a first comment in the next version.
Okay.
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-13 12:45 Dilip Kumar <dilipbalaut@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2026-01-13 12:45 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Peter Smith <smithpb2250@gmail.com>; vignesh C <vignesh21@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, Jan 13, 2026 at 5:02 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> On Tue, Jan 13, 2026 at 4:09 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Tue, Jan 13, 2026 at 3:59 PM shveta malik <shveta.malik@gmail.com> wrote:
> > >
> > > On Sat, Jan 10, 2026 at 6:45 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > >
> > > >>
> > > > Here is the updated patch
> > > >
> > >
> > > Thanks, I will review the code. Few concerns while experimenting with 001 alone:
> > >
> > > 1)
> > > I am able to insert and update rows in pg_conflict.pg_conflict_16394.
> > > It should be restricted.
> > >
> > > 2)
> > > I think we need to block 'SELECT for UPDATE' and similar operations on
> > > CLT. Currently it is allowed on CLT.
> > > See this:
> > >
> > > postgres=# SELECT * FROM pg_toast.pg_toast_3466 for UPDATE;
> > > ERROR: cannot lock rows in TOAST relation "pg_toast_3466"
> > > postgres=# SELECT * FROM pg_conflict.pg_conflict_16394 for UPDATE;
> > > ....
> >
> > I sent an analysis on this a few days ago but realized it only went to
> > Amit. Resending to the full list:
> >
> > So the summary is, currently, all INSERT/UPDATE/DELETE operations are
> > blocked for TOAST tables for safety. However, I have allowed these
> > operations for the pg-conflict table. Unlike TOAST, the system does
> > not internally reference conflict data, so user manipulation doesn't
> > pose a safety risk to the system. If a user modifies or deletes this
> > data, they simply lose their logs without impacting system stability.
> > What are your thoughts on this approach?
> >
>
> I don’t see a strong reason for a user to manually perform INSERT or
> UPDATE here. But on rethinking, I also agree that allowing it does no
> harm. It simply gives the user flexibility to modify data they “own”,
> i.e., data that the system does not internally reference. Personally,
> I’m okay with leaving it unrestricted, but it will be good to document
> it as a NOTE/CAUTION, stating that these DML operations are allowed
> and it is the user’s responsibility to manage any changes they make
> manually.
To clarify, I’m allowing INSERT and UPDATE alongside DELETE not
because I anticipate a specific use case, but to avoid adding
unnecessary code complexity. Since restricting these operations isn't
a safety requirement, I felt it was better to keep the implementation
simple rather than adding extra checks that don't provide real value.
So let's get consensus on this decision and then we can change accordingly.
> >
> > >> I Wrote
> > > I have been exploring the enforcement of these restrictions.
> > > Currently, DML is validated via CheckValidResultRel(). If we do not
> > > explicitly check for the conflict log table in that function, DML
> > > operations, including DELETE, will be permitted. While I am not overly
> > > concerned about users attempting manual INSERT or UPDATE operations.
> >
> > > Now for TRUNCATE, the truncate_check_rel() currently relies on
> > > IsSystemClass() to restrict truncations. Since the conflict namespace
> > > is included in IsSystemClass() to prevent unauthorized DDL, TRUNCATE
> > > is blocked by default. We could allow it by adding an exception in
> > > truncate_check_rel() (e.g., IsSystemClass() &&
> > > !IsConflictNamespace()), but I am unsure if this is necessary. Do we
> > > really need to permit TRUNCATE, or is allowing DELETE sufficient for
> > > user-driven cleanup?
> >
>
> I am okay if we allow DELETE alone.
Thanks for your opinion.
> > >
> > >
> > > 3)
> > > We currently skip ANALYZE on TOAST tables, but I’m not sure whether
> > > the same restriction should apply to CLT. Since users are expected to
> > > query CLT frequently, collecting statistics could be beneficial. Even
> > > though there are currently no indexes or other structures to enable
> > > more optimal plans, having statistics should not harm. Thoughts?
> > >
> > > postgres=# analyze pg_toast.pg_toast_16399;
> > > WARNING: skipping "pg_toast_16399" --- cannot analyze non-tables or
> > > special system tables
> > >
> > > postgres=# analyze pg_conflict.pg_conflict_16394;
> > > ANALYZE
> >
> > I think its good to ANALYZE CLT data because user logically never need
> > to query the toast data, but they would have to query the conflict
> > data.
>
> Right. Do you think we shall allow index creation as well on this
> table? It may help if the table is huge. As an example, a user can
> create an index on relid to query it faster. Currently it is not
> allowed.
That’s an interesting point. I initially considered creating internal
indexes to simplify management and avoid requiring users to have DDL
permissions on the pg_conflict schema. However, I realized that
predefined indexes might be too restrictive for diverse search
requirements. Perhaps we should omit indexes from the initial
version? We can then decide whether to add predefined indexes or allow
external ones based on actual usage patterns and feedback. Thoughts?
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-14 02:05 Peter Smith <smithpb2250@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
2 siblings, 0 replies; 912+ messages in thread
From: Peter Smith @ 2026-01-14 02:05 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Some review comments for patch v21-0001.
======
src/backend/catalog/pg_publication.c
GetAllPublicationRelations:
1.
+ /* Subscription conflict log tables are not published */
if (is_publishable_class(relid, relForm) &&
!(relForm->relispartition && pubviaroot))
result = lappend_oid(result, relid);
You replied that this (redundant) comment had been removed, but it is
still in the patch.
======
src/backend/commands/subscriptioncmds.c
2.
+ ConflictLogDest logdest;
XLogRecPtr lsn;
} SubOpts;
Should you call this member 'conflictlogdest', in case the future
supports other kinds of subscription logging?
======
src/include/replication/conflict.h
3.
+static const ConflictLogColumnDef ConflictLogSchema[] =
+{
+ { .attname = "relid", .atttypid = OIDOID },
+ { .attname = "schemaname", .atttypid = TEXTOID },
+ { .attname = "relname", .atttypid = TEXTOID },
+ { .attname = "conflict_type", .atttypid = TEXTOID },
+ { .attname = "remote_xid", .atttypid = XIDOID },
+ { .attname = "remote_commit_lsn",.atttypid = LSNOID },
+ { .attname = "remote_commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "remote_origin", .atttypid = TEXTOID },
+ { .attname = "replica_identity", .atttypid = JSONOID },
+ { .attname = "remote_tuple", .atttypid = JSONOID },
+ { .attname = "local_conflicts", .atttypid = JSONARRAYOID }
+};
Is "local_conflicts" meant to be a JSON array or a JSONB array? I
think there are lots of comments in the patch 0002 describing it as
"JSONB", but I don't know if those comments are accurate or not. If
those comments are correct, then shouldn't it be saying JSONBARRAYOID
here?
TBH, I think the JSONOID/JSONARRAYOID is correct, but something is
fishy and clashing with patch 0002.
~~~
4.
+ /* Convenience flag for all supported destinations */
+ CONFLICT_LOG_DEST_ALL = (CONFLICT_LOG_DEST_LOG | CONFLICT_LOG_DEST_TABLE)
+} ConflictLogDest;
Is this better described as a "bitmask", instead of a "flag"?
======
src/test/regress/sql/subscription.sql
5.
+DROP SUBSCRIPTION regress_conflict_protection_test;
+
+
RESET SESSION AUTHORIZATION;
Double blank lines.
======
Kind Regards,
Peter Smith.
Fujitsu Australia
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-14 02:09 Peter Smith <smithpb2250@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
2 siblings, 0 replies; 912+ messages in thread
From: Peter Smith @ 2026-01-14 02:09 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, Jan 13, 2026 at 9:28 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
...
> I am not sure does it makes sense to check the tuple from clt for all
> the test cases in 035_conflict.pl or just checking in a few of them is
> sufficient? Thoughts?
>
I felt just having some was OK, not everything.
//////////
Some review comments for patch v21-0002.
======
Commit message
1.
Handling Multi-row Conflicts: A single remote tuple may conflict with multiple
local tuples (e.g., in the case of multiple_unique_conflicts). To handle this,
the infrastructure creates a single row in the conflict log table for each
remote tuple. The details of all conflicting local rows are aggregated into a
single JSON array in the local_conflicts column.
The JSON array uses the following structured format:
[ { "xid": "1001", "commit_ts": "2025-12-25 10:00:00+05:30", "origin": "node_1",
"key": {"id": 1}, "tuple": {"id": 1, "val": "old_data"} }, ... ]
~
As mentioned in the patch 0001 review comments, there is some
ambiguity regarding JSON versus JSONB. Here, the commit message is
calling it a "JSON array"; later in the patch, the same field is
called a "JSONB array".
Also, the code refers to type JSONOID in places where I thought maybe
it should be JSONBOID (assuming the JSONB array comment was right).
So, which one is it meant to be? All the code/comments need to be
consistent.
Meanwhile, according to my AI... "Only use JSON if you absolutely need
to preserve exact formatting or if your workload is heavily
write-biased with almost no reads."
Since the CLT is basically write-only, and human-readable would be
nice too, maybe it's JSON that we want here.
======
src/backend/replication/logical/conflict.c
2.
+ /*
+ * Get both the conflict log destination and the opened conflict log
+ * relation for insertion.
+ */
+ conflictlogrel = GetConflictLogDestAndTable(&dest);
It' not obvious from that comment that the conflictlogrel could be NULL here.
SUGGESTION
Get the conflict log destination. Also, (if there is one) return the
CLT relation already opened and ready for insertion.
~~~
3.
+ errdetail("Conflict details logged to internal table with OID %u.",
+ MySubscription->conflictlogrelid));
Instead of "internal table", it should be called the "conflict log table".
Also, why not give the real name of the CLT instead of making the user
have to figure it out from the relid?
SUGGESTION
"Conflict details are logged to the conflict log table: %s"
(It might be nicer if you have a common util function for returning
the CLT name "pg_conflict_%u" given the subid)
~~~
4.
+/*
+ * GetConflictLogDestAndTable
+ *
+ * Fetches conflict logging metadata from the cached MySubscription pointer.
+ * Sets the destination enum in *log_dest and, if applicable, opens and
+ * returns the relation handle for the internal log table.
+ */
+Relation
+GetConflictLogDestAndTable(ConflictLogDest *log_dest)
/for the internal log table./for the conflict log table./
======
src/test/subscription/t/035_conflicts.pl
5.
+my $json_query = qq[
+ SELECT string_agg((unnested.j::json)->'key'->>'a', ',')
+ FROM (
+ SELECT unnest(local_conflicts) AS j
+ FROM $clt
+ ) AS unnested;
+];
+
+my $all_keys = $node_subscriber->safe_psql('postgres', $json_query);
+
+# Verify that '2' is present in the resulting string
+like($all_keys, qr/\b2\b/, 'Verified that key 2 exists in the
local_conflicts log');
I noticed you are jumping through some hoops to extract fields from
the JSON before checking their content. If the 'local_conflicts'
really is stored as human-readable JSON (not JSONB), then maybe that
extraction work is not necessary. e.g. consider just matching against
a portion of the JSON string, instead of having to unnest/extract
anything at all.
======
Kind Regards,
Peter Smith.
Fujitsu Australia
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-14 02:17 Peter Smith <smithpb2250@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
2 siblings, 0 replies; 912+ messages in thread
From: Peter Smith @ 2026-01-14 02:17 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Some review comments for patch v21-0003.
======
(v21-0003 is identical to v20-0003. None of my v20-0003 review
comments have been addressed yet.)
~~~
Anyway, here is one more review comment:
1.
The DOCS for CREATE SUBSCRIPTION say:
+ <literal>pg_conflict</literal> schema. This allows for
easy querying and
+ analysis of conflicts. This table is automatically
dropped when the
+ subscription is removed.
The DOCS for ALTER SUBSCRIPTION also say:
+ exist. Conversely, if the destination is changed to
+ <literal>log</literal>, logging to the table stops and the internal
+ table is automatically dropped.
~
I felt these topics about the CLT auto-removal need to be more
prominent, like in a <caution> note or something similar, to recommend
that the user must make a *backup* of the CLT first, if they intend to
do post-mortem analysis of the conflict. Maybe the best place to put
this <caution> is in the CREATE SUBSCRIPTION / Parameters /
conflict_log_desttination = table section.
IMO "post-mortem" implies first dropping the subscription (e.g.
because it might be in some kind of apply retry-loop conflicting over
and over and over), and then studying the logs/CLT to figure out where
it went wrong. Therefore, it would be bad if the user wants to exactly
that kind of study, only to find that they've accidentally also
deleted all the CLT evidence when they dropped the subscription.
======
Kind Regards,
Peter Smith.
Fujitsu Australia
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-14 10:29 Amit Kapila <amit.kapila16@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Amit Kapila @ 2026-01-14 10:29 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Peter Smith <smithpb2250@gmail.com>; vignesh C <vignesh21@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, Jan 13, 2026 at 6:15 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Tue, Jan 13, 2026 at 5:02 PM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > On Tue, Jan 13, 2026 at 4:09 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > On Tue, Jan 13, 2026 at 3:59 PM shveta malik <shveta.malik@gmail.com> wrote:
> > > >
> > > > On Sat, Jan 10, 2026 at 6:45 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > > >
> > > > >>
> > > > > Here is the updated patch
> > > > >
> > > >
> > > > Thanks, I will review the code. Few concerns while experimenting with 001 alone:
> > > >
> > > > 1)
> > > > I am able to insert and update rows in pg_conflict.pg_conflict_16394.
> > > > It should be restricted.
> > > >
> > > > 2)
> > > > I think we need to block 'SELECT for UPDATE' and similar operations on
> > > > CLT. Currently it is allowed on CLT.
> > > > See this:
> > > >
> > > > postgres=# SELECT * FROM pg_toast.pg_toast_3466 for UPDATE;
> > > > ERROR: cannot lock rows in TOAST relation "pg_toast_3466"
> > > > postgres=# SELECT * FROM pg_conflict.pg_conflict_16394 for UPDATE;
> > > > ....
> > >
> > > I sent an analysis on this a few days ago but realized it only went to
> > > Amit. Resending to the full list:
> > >
> > > So the summary is, currently, all INSERT/UPDATE/DELETE operations are
> > > blocked for TOAST tables for safety. However, I have allowed these
> > > operations for the pg-conflict table. Unlike TOAST, the system does
> > > not internally reference conflict data, so user manipulation doesn't
> > > pose a safety risk to the system. If a user modifies or deletes this
> > > data, they simply lose their logs without impacting system stability.
> > > What are your thoughts on this approach?
> > >
> >
> > I don’t see a strong reason for a user to manually perform INSERT or
> > UPDATE here. But on rethinking, I also agree that allowing it does no
> > harm. It simply gives the user flexibility to modify data they “own”,
> > i.e., data that the system does not internally reference. Personally,
> > I’m okay with leaving it unrestricted, but it will be good to document
> > it as a NOTE/CAUTION, stating that these DML operations are allowed
> > and it is the user’s responsibility to manage any changes they make
> > manually.
>
> To clarify, I’m allowing INSERT and UPDATE alongside DELETE not
> because I anticipate a specific use case, but to avoid adding
> unnecessary code complexity. Since restricting these operations isn't
> a safety requirement, I felt it was better to keep the implementation
> simple rather than adding extra checks that don't provide real value.
>
What kind of complexity you are anticipating, can you show by top-up
patch? I think allowing just DELETE and TRUNCATE to table owners would
be ideal.
> So let's get consensus on this decision and then we can change accordingly.
>
> > >
> > > >> I Wrote
> > > > I have been exploring the enforcement of these restrictions.
> > > > Currently, DML is validated via CheckValidResultRel(). If we do not
> > > > explicitly check for the conflict log table in that function, DML
> > > > operations, including DELETE, will be permitted. While I am not overly
> > > > concerned about users attempting manual INSERT or UPDATE operations.
> > >
> > > > Now for TRUNCATE, the truncate_check_rel() currently relies on
> > > > IsSystemClass() to restrict truncations. Since the conflict namespace
> > > > is included in IsSystemClass() to prevent unauthorized DDL, TRUNCATE
> > > > is blocked by default. We could allow it by adding an exception in
> > > > truncate_check_rel() (e.g., IsSystemClass() &&
> > > > !IsConflictNamespace()), but I am unsure if this is necessary. Do we
> > > > really need to permit TRUNCATE, or is allowing DELETE sufficient for
> > > > user-driven cleanup?
> > >
> >
> > I am okay if we allow DELETE alone.
>
> Thanks for your opinion.
>
> > > >
> > > >
> > > > 3)
> > > > We currently skip ANALYZE on TOAST tables, but I’m not sure whether
> > > > the same restriction should apply to CLT. Since users are expected to
> > > > query CLT frequently, collecting statistics could be beneficial. Even
> > > > though there are currently no indexes or other structures to enable
> > > > more optimal plans, having statistics should not harm. Thoughts?
> > > >
> > > > postgres=# analyze pg_toast.pg_toast_16399;
> > > > WARNING: skipping "pg_toast_16399" --- cannot analyze non-tables or
> > > > special system tables
> > > >
> > > > postgres=# analyze pg_conflict.pg_conflict_16394;
> > > > ANALYZE
> > >
> > > I think its good to ANALYZE CLT data because user logically never need
> > > to query the toast data, but they would have to query the conflict
> > > data.
> >
> > Right. Do you think we shall allow index creation as well on this
> > table? It may help if the table is huge. As an example, a user can
> > create an index on relid to query it faster. Currently it is not
> > allowed.
>
> That’s an interesting point. I initially considered creating internal
> indexes to simplify management and avoid requiring users to have DDL
> permissions on the pg_conflict schema. However, I realized that
> predefined indexes might be too restrictive for diverse search
> requirements. Perhaps we should omit indexes from the initial
> version? We can then decide whether to add predefined indexes or allow
> external ones based on actual usage patterns and feedback. Thoughts?
>
I agree that the indexes could be considered later unless required by
internal code to search the conflict table.
--
With Regards,
Amit Kapila.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-14 11:39 Dilip Kumar <dilipbalaut@gmail.com>
parent: Amit Kapila <amit.kapila16@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2026-01-14 11:39 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Peter Smith <smithpb2250@gmail.com>; vignesh C <vignesh21@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Wed, Jan 14, 2026 at 3:59 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
>
> On Tue, Jan 13, 2026 at 6:15 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Tue, Jan 13, 2026 at 5:02 PM shveta malik <shveta.malik@gmail.com> wrote:
> > >
> > > On Tue, Jan 13, 2026 at 4:09 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > >
> > > > On Tue, Jan 13, 2026 at 3:59 PM shveta malik <shveta.malik@gmail.com> wrote:
> > > > >
> > > > > On Sat, Jan 10, 2026 at 6:45 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > > > >
> > > > > >>
> > > > > > Here is the updated patch
> > > > > >
> > > > >
> > > > > Thanks, I will review the code. Few concerns while experimenting with 001 alone:
> > > > >
> > > > > 1)
> > > > > I am able to insert and update rows in pg_conflict.pg_conflict_16394.
> > > > > It should be restricted.
> > > > >
> > > > > 2)
> > > > > I think we need to block 'SELECT for UPDATE' and similar operations on
> > > > > CLT. Currently it is allowed on CLT.
> > > > > See this:
> > > > >
> > > > > postgres=# SELECT * FROM pg_toast.pg_toast_3466 for UPDATE;
> > > > > ERROR: cannot lock rows in TOAST relation "pg_toast_3466"
> > > > > postgres=# SELECT * FROM pg_conflict.pg_conflict_16394 for UPDATE;
> > > > > ....
> > > >
> > > > I sent an analysis on this a few days ago but realized it only went to
> > > > Amit. Resending to the full list:
> > > >
> > > > So the summary is, currently, all INSERT/UPDATE/DELETE operations are
> > > > blocked for TOAST tables for safety. However, I have allowed these
> > > > operations for the pg-conflict table. Unlike TOAST, the system does
> > > > not internally reference conflict data, so user manipulation doesn't
> > > > pose a safety risk to the system. If a user modifies or deletes this
> > > > data, they simply lose their logs without impacting system stability.
> > > > What are your thoughts on this approach?
> > > >
> > >
> > > I don’t see a strong reason for a user to manually perform INSERT or
> > > UPDATE here. But on rethinking, I also agree that allowing it does no
> > > harm. It simply gives the user flexibility to modify data they “own”,
> > > i.e., data that the system does not internally reference. Personally,
> > > I’m okay with leaving it unrestricted, but it will be good to document
> > > it as a NOTE/CAUTION, stating that these DML operations are allowed
> > > and it is the user’s responsibility to manage any changes they make
> > > manually.
> >
> > To clarify, I’m allowing INSERT and UPDATE alongside DELETE not
> > because I anticipate a specific use case, but to avoid adding
> > unnecessary code complexity. Since restricting these operations isn't
> > a safety requirement, I felt it was better to keep the implementation
> > simple rather than adding extra checks that don't provide real value.
> >
>
> What kind of complexity you are anticipating, can you show by top-up
> patch? I think allowing just DELETE and TRUNCATE to table owners would
> be ideal.
I mean by code complexity i.e. additional check for DELETE/TRUNCATE,
but if we think that's ideal, I can update it.
> > So let's get consensus on this decision and then we can change accordingly.
> >
> > > >
> > > > >> I Wrote
> > > > > I have been exploring the enforcement of these restrictions.
> > > > > Currently, DML is validated via CheckValidResultRel(). If we do not
> > > > > explicitly check for the conflict log table in that function, DML
> > > > > operations, including DELETE, will be permitted. While I am not overly
> > > > > concerned about users attempting manual INSERT or UPDATE operations.
> > > >
> > > > > Now for TRUNCATE, the truncate_check_rel() currently relies on
> > > > > IsSystemClass() to restrict truncations. Since the conflict namespace
> > > > > is included in IsSystemClass() to prevent unauthorized DDL, TRUNCATE
> > > > > is blocked by default. We could allow it by adding an exception in
> > > > > truncate_check_rel() (e.g., IsSystemClass() &&
> > > > > !IsConflictNamespace()), but I am unsure if this is necessary. Do we
> > > > > really need to permit TRUNCATE, or is allowing DELETE sufficient for
> > > > > user-driven cleanup?
> > > >
> > >
> > > I am okay if we allow DELETE alone.
> >
> > Thanks for your opinion.
> >
> > > > >
> > > > >
> > > > > 3)
> > > > > We currently skip ANALYZE on TOAST tables, but I’m not sure whether
> > > > > the same restriction should apply to CLT. Since users are expected to
> > > > > query CLT frequently, collecting statistics could be beneficial. Even
> > > > > though there are currently no indexes or other structures to enable
> > > > > more optimal plans, having statistics should not harm. Thoughts?
> > > > >
> > > > > postgres=# analyze pg_toast.pg_toast_16399;
> > > > > WARNING: skipping "pg_toast_16399" --- cannot analyze non-tables or
> > > > > special system tables
> > > > >
> > > > > postgres=# analyze pg_conflict.pg_conflict_16394;
> > > > > ANALYZE
> > > >
> > > > I think its good to ANALYZE CLT data because user logically never need
> > > > to query the toast data, but they would have to query the conflict
> > > > data.
> > >
> > > Right. Do you think we shall allow index creation as well on this
> > > table? It may help if the table is huge. As an example, a user can
> > > create an index on relid to query it faster. Currently it is not
> > > allowed.
> >
> > That’s an interesting point. I initially considered creating internal
> > indexes to simplify management and avoid requiring users to have DDL
> > permissions on the pg_conflict schema. However, I realized that
> > predefined indexes might be too restrictive for diverse search
> > requirements. Perhaps we should omit indexes from the initial
> > version? We can then decide whether to add predefined indexes or allow
> > external ones based on actual usage patterns and feedback. Thoughts?
> >
>
> I agree that the indexes could be considered later unless required by
> internal code to search the conflict table.
Thanks.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-16 06:32 Dilip Kumar <dilipbalaut@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 3 replies; 912+ messages in thread
From: Dilip Kumar @ 2026-01-16 06:32 UTC (permalink / raw)
To: Amit Kapila <amit.kapila16@gmail.com>; +Cc: shveta malik <shveta.malik@gmail.com>; Peter Smith <smithpb2250@gmail.com>; vignesh C <vignesh21@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Wed, Jan 14, 2026 at 5:09 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Wed, Jan 14, 2026 at 3:59 PM Amit Kapila <amit.kapila16@gmail.com> wrote:
> >
> > On Tue, Jan 13, 2026 at 6:15 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > On Tue, Jan 13, 2026 at 5:02 PM shveta malik <shveta.malik@gmail.com> wrote:
> > > >
> > > > On Tue, Jan 13, 2026 at 4:09 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > > >
> > > > > On Tue, Jan 13, 2026 at 3:59 PM shveta malik <shveta.malik@gmail.com> wrote:
> > > > > >
> > > > > > On Sat, Jan 10, 2026 at 6:45 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > > > > >
> > > > > > >>
> > > > > > > Here is the updated patch
> > > > > > >
> > > > > >
> > > > > > Thanks, I will review the code. Few concerns while experimenting with 001 alone:
> > > > > >
> > > > > > 1)
> > > > > > I am able to insert and update rows in pg_conflict.pg_conflict_16394.
> > > > > > It should be restricted.
> > > > > >
> > > > > > 2)
> > > > > > I think we need to block 'SELECT for UPDATE' and similar operations on
> > > > > > CLT. Currently it is allowed on CLT.
> > > > > > See this:
> > > > > >
> > > > > > postgres=# SELECT * FROM pg_toast.pg_toast_3466 for UPDATE;
> > > > > > ERROR: cannot lock rows in TOAST relation "pg_toast_3466"
> > > > > > postgres=# SELECT * FROM pg_conflict.pg_conflict_16394 for UPDATE;
> > > > > > ....
> > > > >
> > > > > I sent an analysis on this a few days ago but realized it only went to
> > > > > Amit. Resending to the full list:
> > > > >
> > > > > So the summary is, currently, all INSERT/UPDATE/DELETE operations are
> > > > > blocked for TOAST tables for safety. However, I have allowed these
> > > > > operations for the pg-conflict table. Unlike TOAST, the system does
> > > > > not internally reference conflict data, so user manipulation doesn't
> > > > > pose a safety risk to the system. If a user modifies or deletes this
> > > > > data, they simply lose their logs without impacting system stability.
> > > > > What are your thoughts on this approach?
> > > > >
> > > >
> > > > I don’t see a strong reason for a user to manually perform INSERT or
> > > > UPDATE here. But on rethinking, I also agree that allowing it does no
> > > > harm. It simply gives the user flexibility to modify data they “own”,
> > > > i.e., data that the system does not internally reference. Personally,
> > > > I’m okay with leaving it unrestricted, but it will be good to document
> > > > it as a NOTE/CAUTION, stating that these DML operations are allowed
> > > > and it is the user’s responsibility to manage any changes they make
> > > > manually.
> > >
> > > To clarify, I’m allowing INSERT and UPDATE alongside DELETE not
> > > because I anticipate a specific use case, but to avoid adding
> > > unnecessary code complexity. Since restricting these operations isn't
> > > a safety requirement, I felt it was better to keep the implementation
> > > simple rather than adding extra checks that don't provide real value.
> > >
> >
> > What kind of complexity you are anticipating, can you show by top-up
> > patch? I think allowing just DELETE and TRUNCATE to table owners would
> > be ideal.
>
> I mean by code complexity i.e. additional check for DELETE/TRUNCATE,
> but if we think that's ideal, I can update it.
>
> > > So let's get consensus on this decision and then we can change accordingly.
> > >
> > > > >
> > > > > >> I Wrote
> > > > > > I have been exploring the enforcement of these restrictions.
> > > > > > Currently, DML is validated via CheckValidResultRel(). If we do not
> > > > > > explicitly check for the conflict log table in that function, DML
> > > > > > operations, including DELETE, will be permitted. While I am not overly
> > > > > > concerned about users attempting manual INSERT or UPDATE operations.
> > > > >
> > > > > > Now for TRUNCATE, the truncate_check_rel() currently relies on
> > > > > > IsSystemClass() to restrict truncations. Since the conflict namespace
> > > > > > is included in IsSystemClass() to prevent unauthorized DDL, TRUNCATE
> > > > > > is blocked by default. We could allow it by adding an exception in
> > > > > > truncate_check_rel() (e.g., IsSystemClass() &&
> > > > > > !IsConflictNamespace()), but I am unsure if this is necessary. Do we
> > > > > > really need to permit TRUNCATE, or is allowing DELETE sufficient for
> > > > > > user-driven cleanup?
> > > > >
> > > >
> > > > I am okay if we allow DELETE alone.
> > >
> > > Thanks for your opinion.
> > >
> > > > > >
> > > > > >
> > > > > > 3)
> > > > > > We currently skip ANALYZE on TOAST tables, but I’m not sure whether
> > > > > > the same restriction should apply to CLT. Since users are expected to
> > > > > > query CLT frequently, collecting statistics could be beneficial. Even
> > > > > > though there are currently no indexes or other structures to enable
> > > > > > more optimal plans, having statistics should not harm. Thoughts?
> > > > > >
> > > > > > postgres=# analyze pg_toast.pg_toast_16399;
> > > > > > WARNING: skipping "pg_toast_16399" --- cannot analyze non-tables or
> > > > > > special system tables
> > > > > >
> > > > > > postgres=# analyze pg_conflict.pg_conflict_16394;
> > > > > > ANALYZE
> > > > >
> > > > > I think its good to ANALYZE CLT data because user logically never need
> > > > > to query the toast data, but they would have to query the conflict
> > > > > data.
> > > >
> > > > Right. Do you think we shall allow index creation as well on this
> > > > table? It may help if the table is huge. As an example, a user can
> > > > create an index on relid to query it faster. Currently it is not
> > > > allowed.
So now I have changed this so DELETE/TRUNCATE are allowed and other
DML/DDLs are not allowed, added tests for the same, fixed other open
comments from Peter and also added clt name in \dRs+, haven't added
test for that because it will display the dynamic table name, not sure
what's the best way to hide that and if we hide then what the purpose
of that test anyway because there are other test for \dRs+ when log
destination is log.
--
Regards,
Dilip Kumar
Google
Attachments:
[application/x-patch] v22-0002-Implement-the-conflict-insertion-infrastructure-.patch (28.7K, ../../CAFiTN-vDCxx6ydUFo59L8qNBbierg4as3TGPPiavR7UZjYurzA@mail.gmail.com/2-v22-0002-Implement-the-conflict-insertion-infrastructure-.patch)
download | inline diff:
From 758fd0af99edfd0c55bff8f23d5eab32102790ec Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Mon, 12 Jan 2026 20:21:03 +0530
Subject: [PATCH v22 2/3] Implement the conflict insertion infrastructure for
the conflict log table
This patch introduces the core logic to populate the conflict log table whenever
a logical replication conflict is detected. It captures the remote transaction
details along with the corresponding local state at the time of the conflict.
Handling Multi-row Conflicts: A single remote tuple may conflict with multiple
local tuples (e.g., in the case of multiple_unique_conflicts). To handle this,
the infrastructure creates a single row in the conflict log table for each
remote tuple. The details of all conflicting local rows are aggregated into a
single JSON array in the local_conflicts column.
The JSON array uses the following structured format:
[ { "xid": "1001", "commit_ts": "2025-12-25 10:00:00+05:30", "origin": "node_1",
"key": {"id": 1}, "tuple": {"id": 1, "val": "old_data"} }, ... ]
Example of querying the structured conflict data:
SELECT remote_xid, relname, remote_origin, local_conflicts[1] ->> 'xid' AS local_xid,
local_conflicts[1] ->> 'tuple' AS local_tuple
FROM myschema.conflict_log_history2;
remote_xid | relname | remote_origin | local_xid | local_tuple
------------+----------+---------------+-----------+---------------------
760 | test | pg_16406 | 771 | {"a":1,"b":10}
765 | conf_tab | pg_16406 | 775 | {"a":2,"b":2,"c":2}
---
src/backend/replication/logical/conflict.c | 561 +++++++++++++++++++--
src/backend/replication/logical/launcher.c | 1 +
src/backend/replication/logical/worker.c | 31 +-
src/include/replication/conflict.h | 3 +
src/include/replication/worker_internal.h | 7 +
src/test/subscription/t/035_conflicts.pl | 47 +-
6 files changed, 605 insertions(+), 45 deletions(-)
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 93222ee3b88..99a381cf73b 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -15,13 +15,21 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/tableam.h"
+#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "pgstat.h"
#include "replication/conflict.h"
#include "replication/worker_internal.h"
#include "storage/lmgr.h"
+#include "utils/array.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/lsyscache.h"
+#include "utils/pg_lsn.h"
+#include "utils/json.h"
static const char *const ConflictTypeNames[] = {
[CT_INSERT_EXISTS] = "insert_exists",
@@ -34,6 +42,18 @@ static const char *const ConflictTypeNames[] = {
[CT_MULTIPLE_UNIQUE_CONFLICTS] = "multiple_unique_conflicts"
};
+/* Schema for the elements within the 'local_conflicts' JSON array */
+static const ConflictLogColumnDef LocalConflictSchema[] =
+{
+ { .attname = "xid", .atttypid = XIDOID },
+ { .attname = "commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "origin", .atttypid = TEXTOID },
+ { .attname = "key", .atttypid = JSONOID },
+ { .attname = "tuple", .atttypid = JSONOID }
+};
+
+#define MAX_LOCAL_CONFLICT_INFO_ATTRS lengthof(LocalConflictSchema)
+
static int errcode_apply_conflict(ConflictType type);
static void errdetail_apply_conflict(EState *estate,
ResultRelInfo *relinfo,
@@ -50,8 +70,27 @@ static char *build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *localslot,
TupleTableSlot *remoteslot,
Oid indexoid);
+static void build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull);
static char *build_index_value_desc(EState *estate, Relation localrel,
TupleTableSlot *slot, Oid indexoid);
+static Datum tuple_table_slot_to_json_datum(TupleTableSlot *slot);
+static Datum tuple_table_slot_to_indextup_json(EState *estate,
+ Relation localrel,
+ Oid replica_index,
+ TupleTableSlot *slot);
+static TupleDesc build_conflict_tupledesc(void);
+static Datum build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples);
+static void prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot);
/*
* Get the xmin and commit timestamp data (origin and timestamp) associated
@@ -105,30 +144,90 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
ConflictType type, TupleTableSlot *searchslot,
TupleTableSlot *remoteslot, List *conflicttuples)
{
- Relation localrel = relinfo->ri_RelationDesc;
- StringInfoData err_detail;
+ Relation localrel = relinfo->ri_RelationDesc;
+ ConflictLogDest dest;
+ Relation conflictlogrel;
+ bool log_dest_clt;
+ bool log_dest_logfile;
- initStringInfo(&err_detail);
+ pgstat_report_subscription_conflict(MySubscription->oid, type);
- /* Form errdetail message by combining conflicting tuples information. */
- foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
- errdetail_apply_conflict(estate, relinfo, type, searchslot,
- conflicttuple->slot, remoteslot,
- conflicttuple->indexoid,
- conflicttuple->xmin,
- conflicttuple->origin,
- conflicttuple->ts,
- &err_detail);
+ /*
+ * Get the conflict log destination. Also, (if there is one) return the
+ * CLT relation already opened and ready for insertion.
+ */
+ conflictlogrel = GetConflictLogDestAndTable(&dest);
- pgstat_report_subscription_conflict(MySubscription->oid, type);
+ log_dest_clt = ((dest & CONFLICT_LOG_DEST_TABLE) != 0);
+ log_dest_logfile = ((dest & CONFLICT_LOG_DEST_LOG) != 0);
+
+ /* Insert to table if requested. */
+ if (log_dest_clt)
+ {
+ Assert(conflictlogrel != NULL);
+
+ /*
+ * Prepare the conflict log tuple. If the error level is below ERROR,
+ * insert it immediately. Otherwise, defer the insertion to a new
+ * transaction after the current one aborts, ensuring the insertion of
+ * the log tuple is not rolled back.
+ */
+ prepare_conflict_log_tuple(estate,
+ relinfo->ri_RelationDesc,
+ conflictlogrel,
+ type,
+ searchslot,
+ conflicttuples,
+ remoteslot);
+ if (elevel < ERROR)
+ InsertConflictLogTuple(conflictlogrel);
+
+ if (!log_dest_logfile)
+ {
+ /*
+ * Not logging conflict details to the server log; Report the error
+ * msg but omit raw tuple data from server logs since it's already
+ * captured in the conflict log table.
+ */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail("Conflict details are logged to the conflict log table: %s",
+ RelationGetRelationName(conflictlogrel)));
+ }
+
+ table_close(conflictlogrel, RowExclusiveLock);
+ }
- ereport(elevel,
- errcode_apply_conflict(type),
- errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
- get_namespace_name(RelationGetNamespace(localrel)),
- RelationGetRelationName(localrel),
- ConflictTypeNames[type]),
- errdetail_internal("%s", err_detail.data));
+ /* Log into the server log if requested. */
+ if (log_dest_logfile)
+ {
+ StringInfoData err_detail;
+
+ initStringInfo(&err_detail);
+
+ /* Form errdetail message by combining conflicting tuples information. */
+ foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
+ errdetail_apply_conflict(estate, relinfo, type, searchslot,
+ conflicttuple->slot, remoteslot,
+ conflicttuple->indexoid,
+ conflicttuple->xmin,
+ conflicttuple->origin,
+ conflicttuple->ts,
+ &err_detail);
+
+ /* Standard reporting with full internal details. */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail_internal("%s", err_detail.data));
+ }
}
/*
@@ -162,6 +261,64 @@ InitConflictIndexes(ResultRelInfo *relInfo)
relInfo->ri_onConflictArbiterIndexes = uniqueIndexes;
}
+/*
+ * GetConflictLogDestAndTable
+ *
+ * Fetches conflict logging metadata from the cached MySubscription pointer.
+ * Sets the destination enum in *log_dest and, if applicable, opens and
+ * returns the relation handle for the conflict log table.
+ */
+Relation
+GetConflictLogDestAndTable(ConflictLogDest *log_dest)
+{
+ Oid conflictlogrelid;
+ Relation conflictlogrel = NULL;
+
+ /*
+ * Convert the text log destination to the internal enum. MySubscription
+ * already contains the data from pg_subscription.
+ */
+ *log_dest = GetLogDestination(MySubscription->conflictlogdest);
+
+ /* Quick exit if a conflict log table was not requested. */
+ if ((*log_dest & CONFLICT_LOG_DEST_TABLE) == 0)
+ return NULL;
+
+ conflictlogrelid = MySubscription->conflictlogrelid;
+
+ Assert(OidIsValid(conflictlogrelid));
+
+ conflictlogrel = table_open(conflictlogrelid, RowExclusiveLock);
+ if (conflictlogrel == NULL)
+ elog(ERROR, "could not open conflict log table (OID %u)",
+ conflictlogrelid);
+
+ return conflictlogrel;
+}
+
+/*
+ * InsertConflictLogTuple
+ *
+ * Insert conflict log tuple into the conflict log table. It uses
+ * HEAP_INSERT_NO_LOGICAL to explicitly block logical decoding of the tuple
+ * inserted into the conflict log table.
+ */
+void
+InsertConflictLogTuple(Relation conflictlogrel)
+{
+ int options = HEAP_INSERT_NO_LOGICAL;
+
+ /* A valid tuple must be prepared and stored in MyLogicalRepWorker. */
+ Assert(MyLogicalRepWorker->conflict_log_tuple != NULL);
+
+ heap_insert(conflictlogrel, MyLogicalRepWorker->conflict_log_tuple,
+ GetCurrentCommandId(true), options, NULL);
+
+ /* Free conflict log tuple. */
+ heap_freetuple(MyLogicalRepWorker->conflict_log_tuple);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+}
+
/*
* Add SQLSTATE error code to the current conflict report.
*/
@@ -472,6 +629,40 @@ build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
return tuple_value.data;
}
+/*
+ * Helper function to extract the "raw" index key Datums and their null flags
+ * from a TupleTableSlot, given an already open index descriptor.
+ * This is the reusable core logic.
+ */
+static void
+build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull)
+{
+ TupleTableSlot *tableslot = slot;
+
+ /*
+ * If the slot is a virtual slot, copy it into a heap tuple slot as
+ * FormIndexDatum only works with heap tuple slots.
+ */
+ if (TTS_IS_VIRTUAL(slot))
+ {
+ /* Slot is created within the EState's tuple table */
+ tableslot = table_slot_create(localrel, &estate->es_tupleTable);
+ tableslot = ExecCopySlot(tableslot, slot);
+ }
+
+ /*
+ * Initialize ecxt_scantuple for potential use in FormIndexDatum
+ */
+ GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+
+ /* Form the index datums */
+ FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values,
+ isnull);
+}
+
/*
* Helper functions to construct a string describing the contents of an index
* entry. See BuildIndexValueDescription for details.
@@ -487,41 +678,325 @@ build_index_value_desc(EState *estate, Relation localrel, TupleTableSlot *slot,
Relation indexDesc;
Datum values[INDEX_MAX_KEYS];
bool isnull[INDEX_MAX_KEYS];
- TupleTableSlot *tableslot = slot;
- if (!tableslot)
+ if (!slot)
return NULL;
Assert(CheckRelationOidLockedByMe(indexoid, RowExclusiveLock, true));
indexDesc = index_open(indexoid, NoLock);
- /*
- * If the slot is a virtual slot, copy it into a heap tuple slot as
- * FormIndexDatum only works with heap tuple slots.
- */
- if (TTS_IS_VIRTUAL(slot))
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+
+ index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+
+ index_close(indexDesc, NoLock);
+
+ return index_value;
+}
+
+/*
+ * tuple_table_slot_to_json_datum
+ *
+ * Helper function to convert a TupleTableSlot to Jsonb.
+ */
+static Datum
+tuple_table_slot_to_json_datum(TupleTableSlot *slot)
+{
+ HeapTuple tuple;
+ Datum datum;
+ Datum json;
+
+ Assert(slot != NULL);
+
+ tuple = ExecCopySlotHeapTuple(slot);
+ datum = heap_copy_tuple_as_datum(tuple, slot->tts_tupleDescriptor);
+
+ json = DirectFunctionCall1(row_to_json, datum);
+ heap_freetuple(tuple);
+
+ return json;
+}
+
+/*
+ * tuple_table_slot_to_indextup_json
+ *
+ * Fetch replica identity key from the tuple table slot and convert into a
+ * json datum.
+ */
+static Datum
+tuple_table_slot_to_indextup_json(EState *estate, Relation localrel,
+ Oid indexid, TupleTableSlot *slot)
+{
+ Relation indexDesc;
+ Datum values[INDEX_MAX_KEYS];
+ bool isnull[INDEX_MAX_KEYS];
+ HeapTuple tuple;
+ TupleDesc tupdesc;
+ Datum datum;
+
+ Assert(slot != NULL);
+
+ Assert(CheckRelationOidLockedByMe(indexid, RowExclusiveLock, true));
+
+ indexDesc = index_open(indexid, NoLock);
+
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+ tupdesc = RelationGetDescr(indexDesc);
+
+ /* Bless the tupdesc so it can be looked up by row_to_json. */
+ BlessTupleDesc(tupdesc);
+
+ /* Form the replica identity tuple. */
+ tuple = heap_form_tuple(tupdesc, values, isnull);
+ datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ index_close(indexDesc, NoLock);
+ heap_freetuple(tuple);
+
+ /* Convert to a JSON datum. */
+ return DirectFunctionCall1(row_to_json, datum);
+}
+
+/*
+ * build_conflict_tupledesc
+ *
+ * Build and bless a tuple descriptor for the internal conflict log table
+ * based on the predefined LocalConflictSchema.
+ */
+static TupleDesc
+build_conflict_tupledesc(void)
+{
+ TupleDesc tupdesc;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ for (int i = 0; i < MAX_LOCAL_CONFLICT_INFO_ATTRS; i++)
+ TupleDescInitEntry(tupdesc, (AttrNumber) (i + 1),
+ LocalConflictSchema[i].attname,
+ LocalConflictSchema[i].atttypid,
+ -1, 0);
+
+ BlessTupleDesc(tupdesc);
+
+ return tupdesc;
+}
+
+/*
+ * Builds the local conflicts JSON array column from the list of
+ * ConflictTupleInfo objects.
+ *
+ * Example output structure:
+ * [ { "xid": "1001", "commit_ts": "...", "origin": "...", "tuple": {...} }, ... ]
+ */
+static Datum
+build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples)
+{
+ ListCell *lc;
+ List *json_datums = NIL; /* List to hold the row_to_json results (type json) */
+ Datum *json_datum_array;
+ bool *json_null_array;
+ Datum json_array_datum;
+ int num_conflicts;
+ int i;
+ int16 typlen;
+ bool typbyval;
+ char typalign;
+ TupleDesc tupdesc;
+
+ /* Build local conflicts tuple descriptor. */
+ tupdesc = build_conflict_tupledesc();
+
+ /* Process local conflict tuple list and prepare an array of JSON. */
+ foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
{
- tableslot = table_slot_create(localrel, &estate->es_tupleTable);
- tableslot = ExecCopySlot(tableslot, slot);
+ Datum values[MAX_LOCAL_CONFLICT_INFO_ATTRS] = {0};
+ bool nulls[MAX_LOCAL_CONFLICT_INFO_ATTRS] = {0};
+ char *origin_name = NULL;
+ HeapTuple tuple;
+ Datum json_datum;
+ int attno;
+
+ attno = 0;
+ values[attno++] = TransactionIdGetDatum(conflicttuple->xmin);
+
+ if (conflicttuple->ts)
+ values[attno++] = TimestampTzGetDatum(conflicttuple->ts);
+ else
+ nulls[attno++] = true;
+
+ if (conflicttuple->origin != InvalidRepOriginId)
+ replorigin_by_oid(conflicttuple->origin, true, &origin_name);
+
+ /* Store empty string if origin name for the tuple is NULL. */
+ if (origin_name != NULL)
+ values[attno++] = CStringGetTextDatum(origin_name);
+ else
+ nulls[attno++] = true;
+
+ /*
+ * Add the conflicting key values in the case of a unique constraint
+ * violation.
+ */
+ if (conflict_type == CT_INSERT_EXISTS ||
+ conflict_type == CT_UPDATE_EXISTS ||
+ conflict_type == CT_MULTIPLE_UNIQUE_CONFLICTS)
+ {
+ Oid indexoid = conflicttuple->indexoid;
+
+ Assert(OidIsValid(indexoid) && conflicttuple->slot &&
+ CheckRelationOidLockedByMe(indexoid, RowExclusiveLock,
+ true));
+ values[attno++] =
+ tuple_table_slot_to_indextup_json(estate, rel,
+ indexoid,
+ conflicttuple->slot);
+ }
+ else
+ nulls[attno++] = true;
+
+ /* Convert conflicting tuple to JSON datum. */
+ if (conflicttuple->slot)
+ values[attno] = tuple_table_slot_to_json_datum(conflicttuple->slot);
+ else
+ nulls[attno] = true;
+
+ Assert(attno + 1 == MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ tuple = heap_form_tuple(tupdesc, values, nulls);
+
+ json_datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ /*
+ * Build the higher level JSON datum in format described in function
+ * header.
+ */
+ json_datum = DirectFunctionCall1(row_to_json, json_datum);
+
+ /* Done with the temporary tuple. */
+ heap_freetuple(tuple);
+
+ /* Add to the array element. */
+ json_datums = lappend(json_datums, (void *) json_datum);
}
- /*
- * Initialize ecxt_scantuple for potential use in FormIndexDatum when
- * index expressions are present.
- */
- GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+ num_conflicts = list_length(json_datums);
- /*
- * The values/nulls arrays passed to BuildIndexValueDescription should be
- * the results of FormIndexDatum, which are the "raw" input to the index
- * AM.
- */
- FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values, isnull);
+ json_datum_array = palloc_array(Datum, num_conflicts);
+ json_null_array = palloc0_array(bool, num_conflicts);
- index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+ i = 0;
+ foreach(lc, json_datums)
+ {
+ json_datum_array[i] = (Datum) lfirst(lc);
+ i++;
+ }
- index_close(indexDesc, NoLock);
+ /* Construct the json[] array Datum. */
+ get_typlenbyvalalign(JSONOID, &typlen, &typbyval, &typalign);
+ json_array_datum = PointerGetDatum(construct_array(json_datum_array,
+ num_conflicts,
+ JSONOID,
+ typlen,
+ typbyval,
+ typalign));
+ pfree(json_datum_array);
+ pfree(json_null_array);
+
+ return json_array_datum;
+}
- return index_value;
+/*
+ * prepare_conflict_log_tuple
+ *
+ * This routine prepares a tuple detailing a conflict encountered during
+ * logical replication. The prepared tuple will be stored in
+ * MyLogicalRepWorker->conflict_log_tuple which should be inserted into the
+ * conflict log table by calling InsertConflictLogTuple.
+ */
+static void
+prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot)
+{
+ Datum values[MAX_CONFLICT_ATTR_NUM] = {0};
+ bool nulls[MAX_CONFLICT_ATTR_NUM] = {0};
+ int attno;
+ char *remote_origin = NULL;
+ MemoryContext oldctx;
+
+ Assert(MyLogicalRepWorker->conflict_log_tuple == NULL);
+
+ /* Populate the values and nulls arrays. */
+ attno = 0;
+ values[attno++] = ObjectIdGetDatum(RelationGetRelid(rel));
+
+ values[attno++] =
+ CStringGetTextDatum(get_namespace_name(RelationGetNamespace(rel)));
+
+ values[attno++] = CStringGetTextDatum(RelationGetRelationName(rel));
+
+ values[attno++] = CStringGetTextDatum(ConflictTypeNames[conflict_type]);
+
+ if (TransactionIdIsValid(remote_xid))
+ values[attno++] = TransactionIdGetDatum(remote_xid);
+ else
+ nulls[attno++] = true;
+
+ values[attno++] = LSNGetDatum(remote_final_lsn);
+
+ if (remote_commit_ts > 0)
+ values[attno++] = TimestampTzGetDatum(remote_commit_ts);
+ else
+ nulls[attno++] = true;
+
+ if (replorigin_session_origin != InvalidRepOriginId)
+ replorigin_by_oid(replorigin_session_origin, true, &remote_origin);
+
+ if (remote_origin != NULL)
+ values[attno++] = CStringGetTextDatum(remote_origin);
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(searchslot))
+ {
+ Oid replica_index = GetRelationIdentityOrPK(rel);
+
+ /*
+ * If the table has a valid replica identity index, build the index
+ * json datum from key value. Otherwise, construct it from the complete
+ * tuple in REPLICA IDENTITY FULL cases.
+ */
+ if (OidIsValid(replica_index))
+ values[attno++] = tuple_table_slot_to_indextup_json(estate, rel,
+ replica_index,
+ searchslot);
+ else
+ values[attno++] = tuple_table_slot_to_json_datum(searchslot);
+ }
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(remoteslot))
+ values[attno++] = tuple_table_slot_to_json_datum(remoteslot);
+ else
+ nulls[attno++] = true;
+
+ values[attno] = build_local_conflicts_json_array(estate, rel,
+ conflict_type,
+ conflicttuples);
+
+ Assert(attno + 1 == MAX_CONFLICT_ATTR_NUM);
+
+ oldctx = MemoryContextSwitchTo(ApplyContext);
+ MyLogicalRepWorker->conflict_log_tuple =
+ heap_form_tuple(RelationGetDescr(conflictlogrel), values, nulls);
+ MemoryContextSwitchTo(oldctx);
}
diff --git a/src/backend/replication/logical/launcher.c b/src/backend/replication/logical/launcher.c
index 3ed86480be2..2dda5a44218 100644
--- a/src/backend/replication/logical/launcher.c
+++ b/src/backend/replication/logical/launcher.c
@@ -477,6 +477,7 @@ retry:
worker->oldest_nonremovable_xid = retain_dead_tuples
? MyReplicationSlot->data.xmin
: InvalidTransactionId;
+ worker->conflict_log_tuple = NULL;
worker->last_lsn = InvalidXLogRecPtr;
TIMESTAMP_NOBEGIN(worker->last_send_time);
TIMESTAMP_NOBEGIN(worker->last_recv_time);
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index ad281e7069b..d4be1122603 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -482,7 +482,9 @@ static bool MySubscriptionValid = false;
static List *on_commit_wakeup_workers_subids = NIL;
bool in_remote_transaction = false;
-static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+TransactionId remote_xid = InvalidTransactionId;
+TimestampTz remote_commit_ts = 0;
/* fields valid only when processing streamed transaction */
static bool in_streamed_transaction = false;
@@ -1219,6 +1221,8 @@ apply_handle_begin(StringInfo s)
set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
remote_final_lsn = begin_data.final_lsn;
+ remote_commit_ts = begin_data.committime;
+ remote_xid = begin_data.xid;
maybe_start_skipping_changes(begin_data.final_lsn);
@@ -1745,6 +1749,10 @@ apply_handle_stream_start(StringInfo s)
/* extract XID of the top-level transaction */
stream_xid = logicalrep_read_stream_start(s, &first_segment);
+ remote_xid = stream_xid;
+ remote_final_lsn = InvalidXLogRecPtr;
+ remote_commit_ts = 0;
+
if (!TransactionIdIsValid(stream_xid))
ereport(ERROR,
(errcode(ERRCODE_PROTOCOL_VIOLATION),
@@ -5609,6 +5617,27 @@ start_apply(XLogRecPtr origin_startpos)
pgstat_report_subscription_error(MySubscription->oid,
MyLogicalRepWorker->type);
+ /*
+ * Insert any pending conflict log tuple under a new transaction.
+ */
+ if (MyLogicalRepWorker->conflict_log_tuple != NULL)
+ {
+ Relation conflictlogrel;
+ ConflictLogDest dest;
+
+ StartTransactionCommand();
+ PushActiveSnapshot(GetTransactionSnapshot());
+
+ /* Open conflict log table and insert the tuple. */
+ conflictlogrel = GetConflictLogDestAndTable(&dest);
+ Assert((dest & CONFLICT_LOG_DEST_TABLE) != 0);
+ InsertConflictLogTuple(conflictlogrel);
+ table_close(conflictlogrel, RowExclusiveLock);
+
+ PopActiveSnapshot();
+ CommitTransactionCommand();
+ }
+
PG_RE_THROW();
}
}
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index 4e4f59bb453..aff1db222a4 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -144,4 +144,7 @@ extern void ReportApplyConflict(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *remoteslot,
List *conflicttuples);
extern void InitConflictIndexes(ResultRelInfo *relInfo);
+extern Relation GetConflictLogDestAndTable(ConflictLogDest *log_dest);
+extern void InsertConflictLogTuple(Relation conflictlogrel);
+extern bool ValidateConflictLogTable(Relation rel);
#endif
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index c1285fdd1bc..5bedfc5450f 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -101,6 +101,9 @@ typedef struct LogicalRepWorker
*/
TransactionId oldest_nonremovable_xid;
+ /* A conflict log tuple that is prepared but not yet inserted. */
+ HeapTuple conflict_log_tuple;
+
/* Stats. */
XLogRecPtr last_lsn;
TimestampTz last_send_time;
@@ -256,6 +259,10 @@ extern PGDLLIMPORT bool InitializingApplyWorker;
extern PGDLLIMPORT List *table_states_not_ready;
+extern XLogRecPtr remote_final_lsn;
+extern TimestampTz remote_commit_ts;
+extern TransactionId remote_xid;
+
extern void logicalrep_worker_attach(int slot);
extern LogicalRepWorker *logicalrep_worker_find(LogicalRepWorkerType wtype,
Oid subid, Oid relid,
diff --git a/src/test/subscription/t/035_conflicts.pl b/src/test/subscription/t/035_conflicts.pl
index ddc75e23fb0..a0f3befc06f 100644
--- a/src/test/subscription/t/035_conflicts.pl
+++ b/src/test/subscription/t/035_conflicts.pl
@@ -50,7 +50,7 @@ $node_subscriber->safe_psql(
'postgres',
"CREATE SUBSCRIPTION sub_tab
CONNECTION '$publisher_connstr application_name=$appname'
- PUBLICATION pub_tab;");
+ PUBLICATION pub_tab WITH (conflict_log_destination=all)");
# Wait for initial table sync to finish
$node_subscriber->wait_for_subscription_sync($node_publisher, $appname);
@@ -86,10 +86,35 @@ $node_subscriber->wait_for_log(
.*Key \(c\)=\(4\); existing local row \(4, 4, 4\); remote row \(2, 3, 4\)./,
$log_offset);
+# Verify the contents of the Conflict Log Table (CLT)
+# This section ensures that the clt contains the expected
+# type and specific key data.
+my $subid = $node_subscriber->safe_psql('postgres',
+ "SELECT oid FROM pg_subscription WHERE subname = 'sub_tab';");
+my $clt = "pg_conflict.pg_conflict_$subid";
+
+# Wait for the conflict to be logged in the CLT
+my $log_check = $node_subscriber->poll_query_until(
+ 'postgres',
+ "SELECT count(*) > 0 FROM $clt;"
+);
+
+my $conflict_check = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM $clt WHERE conflict_type = 'multiple_unique_conflicts';");
+is($conflict_check, 1, 'Verified multiple_unique_conflicts logged into conflict log table');
+
+my $json_query = "SELECT local_conflicts FROM $clt;";
+my $raw_json = $node_subscriber->safe_psql('postgres', $json_query);
+
+# Verify that '2' is present inside the JSON structure using a regex
+# This matches the key/value pattern for "a": 2
+like($raw_json, qr/\\"a\\":2/, 'Verified that key 2 exists in the local_conflicts');
+
pass('multiple_unique_conflicts detected during insert');
# Truncate table to get rid of the error
$node_subscriber->safe_psql('postgres', "TRUNCATE conf_tab;");
+$node_subscriber->safe_psql('postgres', "DELETE FROM $clt");
##################################################
# Test multiple_unique_conflicts due to UPDATE
@@ -118,6 +143,26 @@ $node_subscriber->wait_for_log(
.*Key \(c\)=\(8\); existing local row \(8, 8, 8\); remote row \(6, 7, 8\)./,
$log_offset);
+# Verify the contents of the Conflict Log Table (CLT)
+# This section ensures that the CLT contains the expected
+# type and specific key data.
+
+# Wait for the conflict to be logged in the CLT
+$log_check = $node_subscriber->poll_query_until(
+ 'postgres',
+ "SELECT count(*) > 0 FROM $clt;"
+);
+
+$conflict_check = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM $clt WHERE conflict_type = 'multiple_unique_conflicts';");
+is($conflict_check, 1, 'Verified multiple_unique_conflicts logged into conflict log table');
+
+$raw_json = $node_subscriber->safe_psql('postgres', $json_query);
+
+# Verify that '6' is present inside the JSON structure using a regex
+# This matches the key/value pattern for "a": 6
+like($raw_json, qr/\\"a\\":6/, 'Verified that key 6 exists in the local_conflicts');
+
pass('multiple_unique_conflicts detected during update');
# Truncate table to get rid of the error
--
2.49.0
[application/x-patch] v22-0003-Doccumentation-patch.patch (11.0K, ../../CAFiTN-vDCxx6ydUFo59L8qNBbierg4as3TGPPiavR7UZjYurzA@mail.gmail.com/3-v22-0003-Doccumentation-patch.patch)
download | inline diff:
From 8b77d6ff12a47f724c2ad564e18d3bf1399e6907 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Sun, 21 Dec 2025 18:51:57 +0530
Subject: [PATCH v22 3/3] Doccumentation patch
---
doc/src/sgml/logical-replication.sgml | 116 +++++++++++++++++++++-
doc/src/sgml/ref/alter_subscription.sgml | 14 ++-
doc/src/sgml/ref/create_subscription.sgml | 45 +++++++++
3 files changed, 172 insertions(+), 3 deletions(-)
diff --git a/doc/src/sgml/logical-replication.sgml b/doc/src/sgml/logical-replication.sgml
index 68d6efe5114..41517bf2716 100644
--- a/doc/src/sgml/logical-replication.sgml
+++ b/doc/src/sgml/logical-replication.sgml
@@ -289,6 +289,19 @@
option of <command>CREATE SUBSCRIPTION</command> for details.
</para>
+ <para>
+ Conflicts that occur during replication are, by default, logged as plain text
+ in the server log, which can make automated monitoring and analysis difficult.
+ The <command>CREATE SUBSCRIPTION</command> command provides the
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination">
+ <literal>conflict_log_destination</literal></link> option to record detailed
+ conflict information in a structured, queryable format. When this parameter
+ is set to <literal>table</literal> or <literal>all</literal>, the system
+ automatically manages a dedicated conflict log table, which is created and
+ dropped along with the subscription. This significantly improves post-mortem
+ analysis and operational visibility of the replication setup.
+ </para>
+
<sect2 id="logical-replication-subscription-slot">
<title>Logical Replication Slot Management</title>
@@ -2118,7 +2131,98 @@ Publications:
</para>
<para>
- The log format for logical replication conflicts is as follows:
+ When the <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>
+ is set to <literal>table</literal> or <literal>all</literal>, the system
+ automatically creates a new table with a predefined schema to log conflict
+ details. This table is created in the dedicated
+ <literal>pg_conflict</literal> namespace. The name of the conflict log table
+ is pg_conflict_<replaceable>subscription_oid</replaceable>. The schema of this
+ table is detailed in
+ <xref linkend="logical-replication-conflict-log-schema"/>.
+ </para>
+
+ <table id="logical-replication-conflict-log-schema">
+ <title>Conflict Log Table Schema</title>
+ <tgroup cols="3">
+ <thead>
+ <row>
+ <entry>Column</entry>
+ <entry>Type</entry>
+ <entry>Description</entry>
+ </row>
+ </thead>
+ <tbody>
+ <row>
+ <entry><literal>relid</literal></entry>
+ <entry><type>oid</type></entry>
+ <entry>The OID of the local table where the conflict occurred.</entry>
+ </row>
+ <row>
+ <entry><literal>schemaname</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The schema name of the conflicting table.</entry>
+ </row>
+ <row>
+ <entry><literal>relname</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The name of the conflicting table.</entry>
+ </row>
+ <row>
+ <entry><literal>conflict_type</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The type of conflict that occurred (e.g., <literal>insert_exists</literal>).</entry>
+ </row>
+ <row>
+ <entry><literal>remote_xid</literal></entry>
+ <entry><type>xid</type></entry>
+ <entry>The remote transaction ID that caused the conflict.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_commit_lsn</literal></entry>
+ <entry><type>pg_lsn</type></entry>
+ <entry>The final LSN of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_commit_ts</literal></entry>
+ <entry><type>timestamptz</type></entry>
+ <entry>The remote commit timestamp of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_origin</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The origin of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_tuple</literal></entry>
+ <entry><type>json</type></entry>
+ <entry>The JSON representation of the incoming remote row that caused the conflict.</entry>
+ </row>
+ <row>
+ <entry><literal>local_conflicts</literal></entry>
+ <entry><type>json[]</type></entry>
+ <entry>
+ An array of JSON objects representing the local state for each conflict attempt.
+ Each object includes the local transaction ID (<literal>xid</literal>), commit
+ timestamp (<literal>commit_ts</literal>), origin (<literal>origin</literal>),
+ conflicting key data (<literal>key</literal>), and the full local row
+ image (<literal>tuple</literal>).
+ </entry>
+ </row>
+ </tbody>
+ </tgroup>
+ </table>
+
+ <para>
+ The conflicting row data, including the original local tuple and
+ the remote tuple, is stored in <type>JSON</type> columns (<literal>local_tuple</literal>
+ and <literal>remote_tuple</literal>) for flexible querying and analysis.
+ </para>
+
+ <para>
+ If <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>
+ is left at the default setting or explicitly configured as <literal>log</literal>
+ or <literal>all</literal>, logical replication conflicts are logged in the
+ following format:
<synopsis>
LOG: conflict detected on relation "<replaceable>schemaname</replaceable>.<replaceable>tablename</replaceable>": conflict=<replaceable>conflict_type</replaceable>
DETAIL: <replaceable class="parameter">detailed_explanation</replaceable>.
@@ -2412,6 +2516,16 @@ CONTEXT: processing remote data for replication origin "pg_16395" during "INSER
key or replica identity defined for it.
</para>
</listitem>
+
+ <listitem>
+ <para>
+ The internal table automatically created when
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>
+ is set to <literal>table</literal> or <literal>all</literal> is excluded from
+ logical replication. It will not be published, even if a publication on the
+ subscriber is defined using <literal>FOR ALL TABLES</literal>.
+ </para>
+ </listitem>
</itemizedlist>
</sect1>
diff --git a/doc/src/sgml/ref/alter_subscription.sgml b/doc/src/sgml/ref/alter_subscription.sgml
index 27c06439f4f..2de2c3c52fb 100644
--- a/doc/src/sgml/ref/alter_subscription.sgml
+++ b/doc/src/sgml/ref/alter_subscription.sgml
@@ -280,8 +280,9 @@ ALTER SUBSCRIPTION <replaceable class="parameter">name</replaceable> RENAME TO <
<link linkend="sql-createsubscription-params-with-origin"><literal>origin</literal></link>,
<link linkend="sql-createsubscription-params-with-failover"><literal>failover</literal></link>,
<link linkend="sql-createsubscription-params-with-two-phase"><literal>two_phase</literal></link>,
- <link linkend="sql-createsubscription-params-with-retain-dead-tuples"><literal>retain_dead_tuples</literal></link>, and
- <link linkend="sql-createsubscription-params-with-max-retention-duration"><literal>max_retention_duration</literal></link>.
+ <link linkend="sql-createsubscription-params-with-retain-dead-tuples"><literal>retain_dead_tuples</literal></link>,
+ <link linkend="sql-createsubscription-params-with-max-retention-duration"><literal>max_retention_duration</literal></link> and,
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>.
Only a superuser can set <literal>password_required = false</literal>.
</para>
@@ -339,6 +340,15 @@ ALTER SUBSCRIPTION <replaceable class="parameter">name</replaceable> RENAME TO <
<quote><literal>pg_conflict_detection</literal></quote>, created to retain
dead tuples for conflict detection, will be dropped.
</para>
+
+ <para>
+ When the <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>
+ parameter is set to <literal>table</literal> or <literal>all</literal>, the system
+ automatically creates the internal conflict log table if it does not already
+ exist. Conversely, if the destination is changed to
+ <literal>log</literal>, logging to the table stops and the internal
+ table is automatically dropped.
+ </para>
</listitem>
</varlistentry>
diff --git a/doc/src/sgml/ref/create_subscription.sgml b/doc/src/sgml/ref/create_subscription.sgml
index b7dd361294b..3619faf87aa 100644
--- a/doc/src/sgml/ref/create_subscription.sgml
+++ b/doc/src/sgml/ref/create_subscription.sgml
@@ -247,6 +247,51 @@ CREATE SUBSCRIPTION <replaceable class="parameter">subscription_name</replaceabl
</listitem>
</varlistentry>
+ <varlistentry id="sql-createsubscription-params-with-conflict-log-destination">
+ <term><literal>conflict_log_destination</literal> (<type>enum</type>)</term>
+ <listitem>
+ <para>
+ Specifies the destination for recording logical replication conflicts.
+ </para>
+ <caution>
+ <para>
+ The internal conflict logging table is strictly tied to the lifecycle of the
+ subscription or the <literal>conflict_log_destination</literal> setting. If
+ the subscription is dropped, or if the destination is changed to
+ <literal>log</literal>, the table and all its recorded conflict data are
+ <emphasis>permanently deleted</emphasis>. To perform a post-mortem analysis
+ after removing a subscription, users must manually back up or rename the
+ conflict table before the deletion occurs.
+ </para>
+ </caution>
+ <para>
+ The available destinations are:
+ <itemizedlist>
+ <listitem>
+ <para>
+ <literal>log</literal>: Conflict details are recorded in the server log.
+ This is the default behavior.
+ </para>
+ </listitem>
+ <listitem>
+ <para>
+ <literal>table</literal>: The system automatically creates a structured table
+ named <literal>pg_conflict_<subid></literal> in the
+ <literal>pg_conflict</literal> schema. This allows for easy querying and
+ analysis of conflicts. This table is automatically dropped when the
+ subscription is removed.
+ </para>
+ </listitem>
+ <listitem>
+ <para>
+ <literal>all</literal>: Records to both the server log and the table.
+ </para>
+ </listitem>
+ </itemizedlist>
+ </para>
+ </listitem>
+ </varlistentry>
+
<varlistentry id="sql-createsubscription-params-with-copy-data">
<term><literal>copy_data</literal> (<type>boolean</type>)</term>
<listitem>
--
2.49.0
[application/x-patch] v22-0001-Add-configurable-conflict-log-table-for-Logical-.patch (111.8K, ../../CAFiTN-vDCxx6ydUFo59L8qNBbierg4as3TGPPiavR7UZjYurzA@mail.gmail.com/4-v22-0001-Add-configurable-conflict-log-table-for-Logical-.patch)
download | inline diff:
From 586af8e027932dfc31e1fa40f136b2bade6c5133 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 17 Dec 2025 11:53:47 +0530
Subject: [PATCH v22 1/3] Add configurable conflict log table for Logical
Replication
This patch adds a feature to provide a structured, queryable record of all
logical replication conflicts. The current approach of logging conflicts as
plain text in the server logs makes it difficult to query, analyze, and
use for external monitoring and automation.
This patch addresses these limitations by introducing a configurable
conflict_log_destination=('log'/'table'/'all') option in the CREATE SUBSCRIPTION
command.
If the user chooses to enable logging to a table (by selecting 'table' or 'all'),
an internal logging table named conflict_log_table_<subid> is automatically
created within a dedicated, system-managed namespace named pg_conflict. This table
is linked to the subscription via an internal dependency, ensuring it is
automatically dropped when the subscription is removed.
The conflict details, including the original and remote tuples, are stored in JSON
columns, providing a flexible format to accommodate different table schemas.
The log table captures essential attributes such as local and remote transaction IDs,
LSNs, commit timestamps, and conflict type, providing a complete record for post-mortem
analysis.
This feature will make logical replication conflicts easier to monitor and manage,
significantly improving the overall resilience and operability of replication setups.
The conflict log tables will not be included in a publication, even if the publication
is configured to include ALL TABLES or ALL TABLES IN SCHEMA.
---
src/backend/catalog/catalog.c | 27 +-
src/backend/catalog/heap.c | 3 +-
src/backend/catalog/namespace.c | 8 +-
src/backend/catalog/pg_publication.c | 14 +-
src/backend/catalog/pg_subscription.c | 7 +
src/backend/commands/subscriptioncmds.c | 239 ++++++++++-
src/backend/commands/tablecmds.c | 6 +-
src/backend/executor/execMain.c | 18 +
src/bin/psql/describe.c | 16 +-
src/bin/psql/tab-complete.in.c | 8 +-
src/include/catalog/catalog.h | 2 +
src/include/catalog/pg_namespace.dat | 3 +
src/include/catalog/pg_subscription.h | 11 +
src/include/commands/subscriptioncmds.h | 3 +
src/include/replication/conflict.h | 55 +++
src/test/regress/expected/subscription.out | 444 +++++++++++++++++----
src/test/regress/sql/subscription.sql | 217 ++++++++++
src/tools/pgindent/typedefs.list | 2 +
18 files changed, 981 insertions(+), 102 deletions(-)
diff --git a/src/backend/catalog/catalog.c b/src/backend/catalog/catalog.c
index 7be49032934..d438dc682ec 100644
--- a/src/backend/catalog/catalog.c
+++ b/src/backend/catalog/catalog.c
@@ -86,7 +86,8 @@ bool
IsSystemClass(Oid relid, Form_pg_class reltuple)
{
/* IsCatalogRelationOid is a bit faster, so test that first */
- return (IsCatalogRelationOid(relid) || IsToastClass(reltuple));
+ return (IsCatalogRelationOid(relid) || IsToastClass(reltuple)
+ || IsConflictClass(reltuple));
}
/*
@@ -230,6 +231,18 @@ IsToastClass(Form_pg_class reltuple)
return IsToastNamespace(relnamespace);
}
+/*
+ * IsConflictClass - Check if the given pg_class tuple belongs to the conflict
+ * namespace.
+ */
+bool
+IsConflictClass(Form_pg_class reltuple)
+{
+ Oid relnamespace = reltuple->relnamespace;
+
+ return IsConflictNamespace(relnamespace);
+}
+
/*
* IsCatalogNamespace
* True iff namespace is pg_catalog.
@@ -264,6 +277,18 @@ IsToastNamespace(Oid namespaceId)
isTempToastNamespace(namespaceId);
}
+/*
+ * IsConflictNamespace
+ * True iff namespace is pg_conflict.
+ *
+ * Does not perform any catalog accesses.
+ */
+bool
+IsConflictNamespace(Oid namespaceId)
+{
+ return namespaceId == PG_CONFLICT_NAMESPACE;
+}
+
/*
* IsReservedName
diff --git a/src/backend/catalog/heap.c b/src/backend/catalog/heap.c
index 606434823cf..10dadf378a4 100644
--- a/src/backend/catalog/heap.c
+++ b/src/backend/catalog/heap.c
@@ -314,7 +314,8 @@ heap_create(const char *relname,
*/
if (!allow_system_table_mods &&
((IsCatalogNamespace(relnamespace) && relkind != RELKIND_INDEX) ||
- IsToastNamespace(relnamespace)) &&
+ IsToastNamespace(relnamespace) ||
+ IsConflictNamespace(relnamespace)) &&
IsNormalProcessingMode())
ereport(ERROR,
(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
diff --git a/src/backend/catalog/namespace.c b/src/backend/catalog/namespace.c
index c3b79a2ba48..400292fd06b 100644
--- a/src/backend/catalog/namespace.c
+++ b/src/backend/catalog/namespace.c
@@ -3523,7 +3523,7 @@ LookupCreationNamespace(const char *nspname)
*
* We complain if either the old or new namespaces is a temporary schema
* (or temporary toast schema), or if either the old or new namespaces is the
- * TOAST schema.
+ * TOAST schema or the CONFLICT schema.
*/
void
CheckSetNamespace(Oid oldNspOid, Oid nspOid)
@@ -3539,6 +3539,12 @@ CheckSetNamespace(Oid oldNspOid, Oid nspOid)
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("cannot move objects into or out of TOAST schema")));
+
+ /* similarly for CONFLICT schema */
+ if (nspOid == PG_CONFLICT_NAMESPACE || oldNspOid == PG_CONFLICT_NAMESPACE)
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot move objects into or out of CONFLICT schema")));
}
/*
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index 9a4791c573e..a33c33efe0d 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -31,6 +31,7 @@
#include "catalog/pg_publication_rel.h"
#include "catalog/pg_type.h"
#include "commands/publicationcmds.h"
+#include "commands/subscriptioncmds.h"
#include "funcapi.h"
#include "utils/array.h"
#include "utils/builtins.h"
@@ -85,6 +86,15 @@ check_publication_add_relation(Relation targetrel)
errmsg("cannot add relation \"%s\" to publication",
RelationGetRelationName(targetrel)),
errdetail("This operation is not supported for unlogged tables.")));
+
+ /* Can't be conflict log table */
+ if (IsConflictNamespace(RelationGetNamespace(targetrel)))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot add relation \"%s.%s\" to publication",
+ get_namespace_name(RelationGetNamespace(targetrel)),
+ RelationGetRelationName(targetrel)),
+ errdetail("This operation is not supported for conflict log tables.")));
}
/*
@@ -95,7 +105,8 @@ static void
check_publication_add_schema(Oid schemaid)
{
/* Can't be system namespace */
- if (IsCatalogNamespace(schemaid) || IsToastNamespace(schemaid))
+ if (IsCatalogNamespace(schemaid) || IsToastNamespace(schemaid) ||
+ IsConflictNamespace(schemaid))
ereport(ERROR,
(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
errmsg("cannot add schema \"%s\" to publication",
@@ -139,6 +150,7 @@ is_publishable_class(Oid relid, Form_pg_class reltuple)
reltuple->relkind == RELKIND_PARTITIONED_TABLE ||
reltuple->relkind == RELKIND_SEQUENCE) &&
!IsCatalogRelationOid(relid) &&
+ !IsConflictClass(reltuple) &&
reltuple->relpersistence == RELPERSISTENCE_PERMANENT &&
relid >= FirstNormalObjectId;
}
diff --git a/src/backend/catalog/pg_subscription.c b/src/backend/catalog/pg_subscription.c
index 2b103245290..285a598497d 100644
--- a/src/backend/catalog/pg_subscription.c
+++ b/src/backend/catalog/pg_subscription.c
@@ -106,6 +106,7 @@ GetSubscription(Oid subid, bool missing_ok)
sub->retaindeadtuples = subform->subretaindeadtuples;
sub->maxretention = subform->submaxretention;
sub->retentionactive = subform->subretentionactive;
+ sub->conflictlogrelid = subform->subconflictlogrelid;
/* Get conninfo */
datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
@@ -141,6 +142,12 @@ GetSubscription(Oid subid, bool missing_ok)
Anum_pg_subscription_suborigin);
sub->origin = TextDatumGetCString(datum);
+ /* Get conflict log destination */
+ datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
+ tup,
+ Anum_pg_subscription_subconflictlogdest);
+ sub->conflictlogdest = TextDatumGetCString(datum);
+
/* Is the subscription owner a superuser? */
sub->ownersuperuser = superuser_arg(sub->owner);
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index d6674f20fc2..de1977f81ef 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -15,25 +15,31 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/htup_details.h"
#include "access/table.h"
#include "access/twophase.h"
#include "access/xact.h"
#include "catalog/catalog.h"
#include "catalog/dependency.h"
+#include "catalog/heap.h"
#include "catalog/indexing.h"
#include "catalog/namespace.h"
#include "catalog/objectaccess.h"
#include "catalog/objectaddress.h"
+#include "catalog/pg_am_d.h"
#include "catalog/pg_authid_d.h"
#include "catalog/pg_database_d.h"
+#include "catalog/pg_namespace.h"
#include "catalog/pg_subscription.h"
#include "catalog/pg_subscription_rel.h"
#include "catalog/pg_type.h"
+#include "commands/comment.h"
#include "commands/defrem.h"
#include "commands/event_trigger.h"
#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "miscadmin.h"
#include "nodes/makefuncs.h"
#include "pgstat.h"
@@ -51,6 +57,7 @@
#include "utils/lsyscache.h"
#include "utils/memutils.h"
#include "utils/pg_lsn.h"
+#include "utils/regproc.h"
#include "utils/syscache.h"
/*
@@ -75,6 +82,7 @@
#define SUBOPT_MAX_RETENTION_DURATION 0x00008000
#define SUBOPT_LSN 0x00010000
#define SUBOPT_ORIGIN 0x00020000
+#define SUBOPT_CONFLICT_LOG_DEST 0x00040000
/* check if the 'val' has 'bits' set */
#define IsSet(val, bits) (((val) & (bits)) == (bits))
@@ -103,6 +111,7 @@ typedef struct SubOpts
bool retaindeadtuples;
int32 maxretention;
char *origin;
+ ConflictLogDest conflictlogdest;
XLogRecPtr lsn;
} SubOpts;
@@ -135,7 +144,7 @@ static List *merge_publications(List *oldpublist, List *newpublist, bool addpub,
static void ReportSlotConnectionError(List *rstates, Oid subid, char *slotname, char *err);
static void CheckAlterSubOption(Subscription *sub, const char *option,
bool slot_needs_update, bool isTopLevel);
-
+static Oid create_conflict_log_table(Oid subid, char *subname);
/*
* Common option parsing function for CREATE and ALTER SUBSCRIPTION commands.
@@ -191,6 +200,8 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->maxretention = 0;
if (IsSet(supported_opts, SUBOPT_ORIGIN))
opts->origin = pstrdup(LOGICALREP_ORIGIN_ANY);
+ if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DEST))
+ opts->conflictlogdest = CONFLICT_LOG_DEST_LOG;
/* Parse options */
foreach(lc, stmt_options)
@@ -402,6 +413,18 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->specified_opts |= SUBOPT_LSN;
opts->lsn = lsn;
}
+ else if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DEST) &&
+ strcmp(defel->defname, "conflict_log_destination") == 0)
+ {
+ char *val;
+
+ if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_LOG_DEST))
+ errorConflictingDefElem(defel, pstate);
+
+ val = defGetString(defel);
+ opts->conflictlogdest = GetLogDestination(val);
+ opts->specified_opts |= SUBOPT_CONFLICT_LOG_DEST;
+ }
else
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
@@ -599,6 +622,7 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bits32 supported_opts;
SubOpts opts = {0};
AclResult aclresult;
+ Oid logrelid = InvalidOid;
/*
* Parse and check options.
@@ -612,7 +636,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
SUBOPT_DISABLE_ON_ERR | SUBOPT_PASSWORD_REQUIRED |
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
- SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN);
+ SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_DEST);
parse_subscription_options(pstate, stmt->options, supported_opts, &opts);
/*
@@ -747,6 +772,18 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
values[Anum_pg_subscription_suborigin - 1] =
CStringGetTextDatum(opts.origin);
+ /* Always set the destination, default will be 'log'. */
+ values[Anum_pg_subscription_subconflictlogdest - 1] =
+ CStringGetTextDatum(ConflictLogDestNames[opts.conflictlogdest]);
+
+ /* If logging to a table is required, physically create the table. */
+ if (IsSet(opts.conflictlogdest, CONFLICT_LOG_DEST_TABLE))
+ logrelid = create_conflict_log_table(subid, stmt->subname);
+
+ /* Store table OID in the catalog. */
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(logrelid);
+
tup = heap_form_tuple(RelationGetDescr(rel), values, nulls);
/* Insert tuple into catalog. */
@@ -1410,7 +1447,8 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
SUBOPT_MAX_RETENTION_DURATION |
- SUBOPT_ORIGIN);
+ SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_DEST);
parse_subscription_options(pstate, stmt->options,
supported_opts, &opts);
@@ -1665,6 +1703,63 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
origin = opts.origin;
}
+ if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_LOG_DEST))
+ {
+ ConflictLogDest old_dest =
+ GetLogDestination(sub->conflictlogdest);
+
+ if (opts.conflictlogdest != old_dest)
+ {
+ bool want_table = IsSet(opts.conflictlogdest,
+ CONFLICT_LOG_DEST_TABLE);
+ bool has_oldtable =
+ IsSet(old_dest, CONFLICT_LOG_DEST_TABLE);
+
+ values[Anum_pg_subscription_subconflictlogdest - 1] =
+ CStringGetTextDatum(ConflictLogDestNames[opts.conflictlogdest]);
+ replaces[Anum_pg_subscription_subconflictlogdest - 1] = true;
+
+ if (want_table && !has_oldtable)
+ {
+ Oid relid;
+
+ relid = create_conflict_log_table(subid, sub->name);
+
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(relid);
+ replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
+ true;
+ }
+ else if (!want_table && has_oldtable)
+ {
+ ObjectAddress object;
+
+ /*
+ * Conflict log tables are recorded as internal
+ * dependencies of the subscription. Drop the
+ * table if it is not required anymore to avoid
+ * stale or orphaned relations.
+ *
+ * XXX: At present, only conflict log tables are
+ * managed this way. In future if we introduce
+ * additional internal dependencies, we may need
+ * a targeted deletion to avoid deletion of any
+ * other objects.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId,
+ subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(InvalidOid);
+ replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
+ true;
+ }
+ }
+ }
+
update_tuple = true;
break;
}
@@ -2027,6 +2122,7 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
Form_pg_subscription form;
List *rstates;
bool must_use_password;
+ ObjectAddress object;
/*
* The launcher may concurrently start a new worker for this subscription.
@@ -2184,6 +2280,19 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
/* Clean up dependencies */
deleteSharedDependencyRecordsFor(SubscriptionRelationId, subid, 0);
+ /*
+ * Conflict log tables are recorded as internal dependencies of the
+ * subscription. We must drop the dependent objects before the
+ * subscription itself is removed. By using
+ * PERFORM_DELETION_SKIP_ORIGINAL, we ensure that only the conflict log
+ * table is reaped while the subscription remains for the final deletion
+ * step.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId, subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
/* Remove any associated relation synchronization states. */
RemoveSubscriptionRel(subid, InvalidOid);
@@ -3190,3 +3299,127 @@ defGetStreamingMode(DefElem *def)
def->defname)));
return LOGICALREP_STREAM_OFF; /* keep compiler quiet */
}
+
+/*
+ * Builds the TupleDesc for the conflict log table.
+ */
+static TupleDesc
+create_conflict_log_table_tupdesc(void)
+{
+ TupleDesc tupdesc;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_CONFLICT_ATTR_NUM);
+
+ for (int i = 0; i < MAX_CONFLICT_ATTR_NUM; i++)
+ {
+ Oid type_oid = ConflictLogSchema[i].atttypid;
+
+ /*
+ * Special handling for the JSON array type for proper
+ * TupleDescInitEntry call.
+ */
+ if (type_oid == JSONARRAYOID)
+ type_oid = get_array_type(JSONOID);
+
+ TupleDescInitEntry(tupdesc, i + 1,
+ ConflictLogSchema[i].attname,
+ type_oid,
+ -1, 0);
+ }
+
+ return BlessTupleDesc(tupdesc);
+}
+
+/*
+ * Create a structured conflict log table for a subscription.
+ *
+ * The table is created within the system-managed 'pg_conflict' namespace.
+ * The table name is generated automatically using the subscription's OID
+ * (e.g., "pg_conflict_<subid>") to ensure uniqueness within the cluster and
+ * to avoid collisions during subscription renames.
+ */
+static Oid
+create_conflict_log_table(Oid subid, char *subname)
+{
+ TupleDesc tupdesc;
+ Oid relid;
+ ObjectAddress myself;
+ ObjectAddress subaddr;
+ char relname[NAMEDATALEN];
+
+ snprintf(relname, NAMEDATALEN, "pg_conflict_%u", subid);
+
+ /* There can not be an existing table with the same name. */
+ Assert(!OidIsValid(get_relname_relid(relname, PG_CONFLICT_NAMESPACE)));
+
+ /* Build the tuple descriptor for the new table. */
+ tupdesc = create_conflict_log_table_tupdesc();
+
+ /* Create conflict log table. */
+ relid = heap_create_with_catalog(relname,
+ PG_CONFLICT_NAMESPACE,
+ 0, /* tablespace */
+ InvalidOid, /* relid */
+ InvalidOid, /* reltypeid */
+ InvalidOid, /* reloftypeid */
+ GetUserId(),
+ HEAP_TABLE_AM_OID,
+ tupdesc,
+ NIL,
+ RELKIND_RELATION,
+ RELPERSISTENCE_PERMANENT,
+ false, /* shared_relation */
+ false, /* mapped_relation */
+ ONCOMMIT_NOOP,
+ (Datum) 0, /* reloptions */
+ false, /* use_user_acl */
+ true, /* allow_system_table_mods */
+ true, /* is_internal */
+ InvalidOid, /* relrewrite */
+ NULL); /* typaddress */
+
+ /*
+ * Establish an internal dependency between the conflict log table and
+ * the subscription.
+ *
+ * We use DEPENDENCY_INTERNAL to signify that the table's lifecycle is
+ * strictly tied to the subscription, similar to how a TOAST table relates
+ * to its main table or a sequence relates to an identity column.
+ *
+ * This ensures the conflict log table is automatically reaped during a
+ * DROP SUBSCRIPTION via performDeletion().
+ */
+ ObjectAddressSet(myself, RelationRelationId, relid);
+ ObjectAddressSet(subaddr, SubscriptionRelationId, subid);
+ recordDependencyOn(&myself, &subaddr, DEPENDENCY_INTERNAL);
+
+ /* Release tuple descriptor memory. */
+ FreeTupleDesc(tupdesc);
+
+ return relid;
+}
+
+/*
+ * GetLogDestination
+ *
+ * Convert string to enum by comparing against standardized labels.
+ */
+ConflictLogDest
+GetLogDestination(const char *dest)
+{
+ /* Empty string or NULL defaults to LOG. */
+ if (dest == NULL || dest[0] == '\0' || pg_strcasecmp(dest, "log") == 0)
+ return CONFLICT_LOG_DEST_LOG;
+
+ if (pg_strcasecmp(dest, "table") == 0)
+ return CONFLICT_LOG_DEST_TABLE;
+
+ if (pg_strcasecmp(dest, "all") == 0)
+ return CONFLICT_LOG_DEST_ALL;
+
+ /* Unrecognized string. */
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("unrecognized conflict_log_destination value: \"%s\"", dest),
+ errhint("Valid values are \"log\", \"table\", and \"all\".")));
+}
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index f976c0e5c7e..4bf39f359d7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -2398,9 +2398,11 @@ truncate_check_rel(Oid relid, Form_pg_class reltuple)
* pg_largeobject and pg_largeobject_metadata to be truncated as part of
* pg_upgrade, because we need to change its relfilenode to match the old
* cluster, and allowing a TRUNCATE command to be executed is the easiest
- * way of doing that.
+ * way of doing that. We also allow TRUNCATE on the conflict log tables,
+ * to permit users to manually prune these logs to manage disk space.
*/
- if (!allowSystemTableMods && IsSystemClass(relid, reltuple)
+ if (!allowSystemTableMods && IsSystemClass(relid, reltuple) &&
+ !IsConflictClass(reltuple)
&& (!IsBinaryUpgrade ||
(relid != LargeObjectRelationId &&
relid != LargeObjectMetadataRelationId)))
diff --git a/src/backend/executor/execMain.c b/src/backend/executor/execMain.c
index ca14cdabdd0..ff67a594d39 100644
--- a/src/backend/executor/execMain.c
+++ b/src/backend/executor/execMain.c
@@ -1166,6 +1166,24 @@ CheckValidResultRel(ResultRelInfo *resultRelInfo, CmdType operation,
RelationGetRelationName(resultRel))));
break;
}
+
+ /*
+ * Conflict logging tables (CLT) are managed by the system to record
+ * replication conflicts. We allow DELETE to permit users to manually prune
+ * or truncate these logs, but manual data insertion or modification
+ * (INSERT, UPDATE, MERGE) is prohibited to maintain the integrity of the
+ * system-generated logs.
+ */
+ if (IsConflictNamespace(RelationGetNamespace(resultRel)) &&
+ operation != CMD_DELETE)
+ ereport(ERROR,
+ (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+ errmsg("cannot execute %s on conflict logging table \"%s\"",
+ (operation == CMD_INSERT ? "INSERT" :
+ operation == CMD_UPDATE ? "UPDATE" :
+ operation == CMD_MERGE ? "MERGE" : "this operation"),
+ RelationGetRelationName(resultRel)),
+ errdetail("Conflict logging tables are system-managed and only support cleanup via DELETE or TRUNCATE.")));
}
/*
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index 3584c4e1428..7262541bcbd 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -6806,7 +6806,7 @@ describeSubscriptions(const char *pattern, bool verbose)
printQueryOpt myopt = pset.popt;
static const bool translate_columns[] = {false, false, false, false,
false, false, false, false, false, false, false, false, false, false,
- false, false, false, false};
+ false, false, false, false, false, false};
if (pset.sversion < 100000)
{
@@ -6900,6 +6900,20 @@ describeSubscriptions(const char *pattern, bool verbose)
appendPQExpBuffer(&buf,
", subskiplsn AS \"%s\"\n",
gettext_noop("Skip LSN"));
+
+ /* Conflict log destination is supported in v19 and higher */
+ if (pset.sversion >= 190000)
+ {
+ appendPQExpBuffer(&buf,
+ ", subconflictlogdest AS \"%s\"\n",
+ gettext_noop("Conflict log destination"));
+
+ appendPQExpBuffer(&buf,
+ ", (CASE WHEN subconflictlogdest IN ('table', 'all') "
+ " THEN 'pg_conflict.pg_conflict_' || oid "
+ " ELSE '-' END) AS \"%s\"\n",
+ gettext_noop("Conflict log table"));
+ }
}
/* Only display subscriptions in current database. */
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 8b91bc00062..12eee8a0d43 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -2344,8 +2344,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("(", "PUBLICATION");
/* ALTER SUBSCRIPTION <name> SET ( */
else if (Matches("ALTER", "SUBSCRIPTION", MatchAny, MatchAnyN, "SET", "("))
- COMPLETE_WITH("binary", "disable_on_error", "failover",
- "max_retention_duration", "origin",
+ COMPLETE_WITH("binary", "conflict_log_destination", "disable_on_error",
+ "failover", "max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
"synchronous_commit", "two_phase");
@@ -3860,8 +3860,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("WITH (");
/* Complete "CREATE SUBSCRIPTION <name> ... WITH ( <opt>" */
else if (Matches("CREATE", "SUBSCRIPTION", MatchAnyN, "WITH", "("))
- COMPLETE_WITH("binary", "connect", "copy_data", "create_slot",
- "disable_on_error", "enabled", "failover",
+ COMPLETE_WITH("binary", "conflict_log_destination", "connect", "copy_data",
+ "create_slot", "disable_on_error", "enabled", "failover",
"max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
diff --git a/src/include/catalog/catalog.h b/src/include/catalog/catalog.h
index a9d6e8ea986..8193229f2e2 100644
--- a/src/include/catalog/catalog.h
+++ b/src/include/catalog/catalog.h
@@ -25,6 +25,7 @@ extern bool IsInplaceUpdateRelation(Relation relation);
extern bool IsSystemClass(Oid relid, Form_pg_class reltuple);
extern bool IsToastClass(Form_pg_class reltuple);
+extern bool IsConflictClass(Form_pg_class reltuple);
extern bool IsCatalogRelationOid(Oid relid);
extern bool IsCatalogTextUniqueIndexOid(Oid relid);
@@ -32,6 +33,7 @@ extern bool IsInplaceUpdateOid(Oid relid);
extern bool IsCatalogNamespace(Oid namespaceId);
extern bool IsToastNamespace(Oid namespaceId);
+extern bool IsConflictNamespace(Oid namespaceId);
extern bool IsReservedName(const char *name);
diff --git a/src/include/catalog/pg_namespace.dat b/src/include/catalog/pg_namespace.dat
index 3075e142c73..b45cb9383a8 100644
--- a/src/include/catalog/pg_namespace.dat
+++ b/src/include/catalog/pg_namespace.dat
@@ -18,6 +18,9 @@
{ oid => '99', oid_symbol => 'PG_TOAST_NAMESPACE',
descr => 'reserved schema for TOAST tables',
nspname => 'pg_toast', nspacl => '_null_' },
+{ oid => '1382', oid_symbol => 'PG_CONFLICT_NAMESPACE',
+ descr => 'reserved schema for subscription-specific conflict log tables',
+ nspname => 'pg_conflict', nspacl => '_null_' },
# update dumpNamespace() if changing this descr
{ oid => '2200', oid_symbol => 'PG_PUBLIC_NAMESPACE',
descr => 'standard public schema',
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index f3571d2bfcf..76a4638b389 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -90,6 +90,7 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
* exceeded max_retention_duration, when
* defined */
+ Oid subconflictlogrelid; /* Relid of the conflict log table. */
#ifdef CATALOG_VARLEN /* variable-length fields start here */
/* Connection string to the publisher */
text subconninfo BKI_FORCE_NOT_NULL;
@@ -103,6 +104,14 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
/* List of publications subscribed to */
text subpublications[1] BKI_FORCE_NOT_NULL;
+ /*
+ * Strategy for logging replication conflicts:
+ * 'log' - server log only,
+ * 'table' - conflict log table only,
+ * 'all' - both log and table.
+ */
+ text subconflictlogdest;
+
/* Only publish data originating from the specified origin */
text suborigin BKI_DEFAULT(LOGICALREP_ORIGIN_ANY);
#endif
@@ -152,12 +161,14 @@ typedef struct Subscription
* and the retention duration has not
* exceeded max_retention_duration, when
* defined */
+ Oid conflictlogrelid; /* conflict log table Oid */
char *conninfo; /* Connection string to the publisher */
char *slotname; /* Name of the replication slot */
char *synccommit; /* Synchronous commit setting for worker */
List *publications; /* List of publication names to subscribe to */
char *origin; /* Only publish data originating from the
* specified origin */
+ char *conflictlogdest; /* Conflict log destination */
} Subscription;
#ifdef EXPOSE_TO_CLIENT_CODE
diff --git a/src/include/commands/subscriptioncmds.h b/src/include/commands/subscriptioncmds.h
index 63504232a14..a895127f8fe 100644
--- a/src/include/commands/subscriptioncmds.h
+++ b/src/include/commands/subscriptioncmds.h
@@ -17,6 +17,7 @@
#include "catalog/objectaddress.h"
#include "parser/parse_node.h"
+#include "replication/conflict.h"
extern ObjectAddress CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bool isTopLevel);
@@ -36,4 +37,6 @@ extern void CheckSubDeadTupleRetention(bool check_guc, bool sub_disabled,
bool retention_active,
bool max_retention_set);
+extern ConflictLogDest GetLogDestination(const char *dest);
+
#endif /* SUBSCRIPTIONCMDS_H */
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index d538274637f..4e4f59bb453 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -10,6 +10,7 @@
#define CONFLICT_H
#include "access/xlogdefs.h"
+#include "catalog/pg_type.h"
#include "nodes/pg_list.h"
#include "utils/timestamp.h"
@@ -79,6 +80,60 @@ typedef struct ConflictTupleInfo
* conflicting local row occurred */
} ConflictTupleInfo;
+/*
+ * Conflict log destination types.
+ *
+ * These values are defined as bitmask flags to allow for multiple simultaneous
+ * logging destinations (e.g., logging to both system logs and a table).
+ * Internally, we use these for bitwise comparisons (IsSet), but the string
+ * representation is stored in pg_subscription.subconflictlogdest.
+ */
+typedef enum ConflictLogDest
+{
+ /* Log conflicts to the server logs */
+ CONFLICT_LOG_DEST_LOG = 1 << 0, /* 0x01 */
+
+ /* Log conflicts to an internally managed conflict log table */
+ CONFLICT_LOG_DEST_TABLE = 1 << 1, /* 0x02 */
+
+ /* Convenience bitmask for all supported destinations */
+ CONFLICT_LOG_DEST_ALL = (CONFLICT_LOG_DEST_LOG | CONFLICT_LOG_DEST_TABLE)
+} ConflictLogDest;
+
+/*
+ * Array mapping for converting internal enum to string.
+ */
+static const char *const ConflictLogDestNames[] = {
+ [CONFLICT_LOG_DEST_LOG] = "log",
+ [CONFLICT_LOG_DEST_TABLE] = "table",
+ [CONFLICT_LOG_DEST_ALL] = "all"
+};
+
+/* Structure to hold metadata for one column of the conflict log table */
+typedef struct ConflictLogColumnDef
+{
+ const char *attname; /* Column name */
+ Oid atttypid; /* Data type OID */
+} ConflictLogColumnDef;
+
+/* The single source of truth for the conflict log table schema */
+static const ConflictLogColumnDef ConflictLogSchema[] =
+{
+ { .attname = "relid", .atttypid = OIDOID },
+ { .attname = "schemaname", .atttypid = TEXTOID },
+ { .attname = "relname", .atttypid = TEXTOID },
+ { .attname = "conflict_type", .atttypid = TEXTOID },
+ { .attname = "remote_xid", .atttypid = XIDOID },
+ { .attname = "remote_commit_lsn",.atttypid = LSNOID },
+ { .attname = "remote_commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "remote_origin", .atttypid = TEXTOID },
+ { .attname = "replica_identity", .atttypid = JSONOID },
+ { .attname = "remote_tuple", .atttypid = JSONOID },
+ { .attname = "local_conflicts", .atttypid = JSONARRAYOID }
+};
+
+#define MAX_CONFLICT_ATTR_NUM lengthof(ConflictLogSchema)
+
extern bool GetTupleTransactionInfo(TupleTableSlot *localslot,
TransactionId *xmin,
RepOriginId *localorigin,
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index b3eccd8afe3..b94bcc3cc23 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -116,18 +116,18 @@ CREATE SUBSCRIPTION regress_testsub4 CONNECTION 'dbname=regress_doesnotexist' PU
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub4 SET (origin = any);
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
DROP SUBSCRIPTION regress_testsub3;
@@ -145,10 +145,10 @@ ALTER SUBSCRIPTION regress_testsub CONNECTION 'foobar';
ERROR: invalid connection string syntax: missing "=" after "foobar" in connection info string
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET PUBLICATION testpub2, testpub3 WITH (refresh = false);
@@ -157,10 +157,10 @@ ALTER SUBSCRIPTION regress_testsub SET (slot_name = 'newname');
ALTER SUBSCRIPTION regress_testsub SET (password_required = false);
ALTER SUBSCRIPTION regress_testsub SET (run_as_owner = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (password_required = true);
@@ -176,10 +176,10 @@ ERROR: unrecognized subscription parameter: "create_slot"
-- ok
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/12345');
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345 | log | -
(1 row)
-- ok - with lsn = NONE
@@ -188,10 +188,10 @@ ALTER SUBSCRIPTION regress_testsub SKIP (lsn = NONE);
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/0');
ERROR: invalid WAL location (LSN): 0/0
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log | -
(1 row)
BEGIN;
@@ -223,10 +223,10 @@ ALTER SUBSCRIPTION regress_testsub_foo SET (synchronous_commit = foobar);
ERROR: invalid value for parameter "synchronous_commit": "foobar"
HINT: Available values: local, remote_write, remote_apply, on, off.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
----------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+---------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------+--------------------
+ regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000 | log | -
(1 row)
-- rename back to keep the rest simple
@@ -255,19 +255,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (binary = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -279,27 +279,27 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = parallel);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
-- fail - publication already exists
@@ -314,10 +314,10 @@ ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refr
ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refresh = false);
ERROR: publication "testpub1" is already in subscription "regress_testsub"
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
-- fail - publication used more than once
@@ -332,10 +332,10 @@ ERROR: publication "testpub3" is not in subscription "regress_testsub"
-- ok - delete publications
ALTER SUBSCRIPTION regress_testsub DROP PUBLICATION testpub1, testpub2 WITH (refresh = false);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -371,19 +371,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
-- we can alter streaming when two_phase enabled
ALTER SUBSCRIPTION regress_testsub SET (streaming = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -393,10 +393,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -409,18 +409,18 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (disable_on_error = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -433,10 +433,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -450,19 +450,19 @@ NOTICE: max_retention_duration is ineffective when retain_dead_tuples is disabl
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
-- ok
ALTER SUBSCRIPTION regress_testsub SET (max_retention_duration = 0);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -517,6 +517,274 @@ COMMIT;
-- ok, owning it is enough for this stuff
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG DESTINATION TESTS
+--
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+-- fail - unrecognized parameter value
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
+ERROR: unrecognized conflict_log_destination value: "invalid"
+HINT: Valid values are "log", "table", and "all".
+-- verify subconflictlogdest is 'log' and relid is 0 (InvalidOid) for default case
+CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
+ subname | subconflictlogdest | subconflictlogrelid
+------------------------------+--------------------+---------------------
+ regress_conflict_log_default | log | 0
+(1 row)
+
+-- verify empty string defaults to 'log'
+CREATE SUBSCRIPTION regress_conflict_empty_str CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = '');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
+ subname | subconflictlogdest | subconflictlogrelid
+----------------------------+--------------------+---------------------
+ regress_conflict_empty_str | log | 0
+(1 row)
+
+-- this should generate an internal conflict log table named pg_conflict_$subid$
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- check metadata in pg_subscription: destination should be 'table' and relid valid
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname | subconflictlogdest | has_relid
+------------------------+--------------------+-----------
+ regress_conflict_test1 | table | t
+(1 row)
+
+-- verify the physical table exists, its OID matches subconflictlogrelid,
+-- and it is located in the 'pg_conflict' namespace
+SELECT n.nspname, (c.oid = s.subconflictlogrelid) AS "oid_matches"
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+JOIN pg_namespace n ON c.relnamespace = n.oid
+WHERE s.subname = 'regress_conflict_test1';
+ nspname | oid_matches
+-------------+-------------
+ pg_conflict | t
+(1 row)
+
+-- check if the conflict log table has the correct schema
+SELECT a.attnum, a.attname
+FROM pg_attribute a
+JOIN pg_class c ON a.attrelid = c.oid
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0
+ ORDER BY a.attnum;
+ attnum | attname
+--------+-------------------
+ 1 | relid
+ 2 | schemaname
+ 3 | relname
+ 4 | conflict_type
+ 5 | remote_xid
+ 6 | remote_commit_lsn
+ 7 | remote_commit_ts
+ 8 | remote_origin
+ 9 | replica_identity
+ 10 | remote_tuple
+ 11 | local_conflicts
+(11 rows)
+
+--
+-- ALTER SUBSCRIPTION - conflict_log_destination state transitions
+--
+-- These tests verify the transition logic between different logging
+-- destinations, ensuring conflict log tables are created or dropped as
+-- expected
+--
+-- transition from 'log' to 'all'
+-- a new internal conflict log table should be created
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
+-- verify metadata after ALTER (destination should be 'all')
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogdest | has_relid
+------------------------+--------------------+-----------
+ regress_conflict_test2 | all | t
+(1 row)
+
+-- transition from 'all' to 'table'
+-- should NOT drop the table, only change destination string
+SELECT subconflictlogrelid AS old_relid FROM pg_subscription WHERE subname = 'regress_conflict_test2' \gset
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'table');
+SELECT subconflictlogdest, subconflictlogrelid = :old_relid AS relid_unchanged
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subconflictlogdest | relid_unchanged
+--------------------+-----------------
+ table | t
+(1 row)
+
+-- transition from 'table' to 'log'
+-- should drop the table and clear relid
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log');
+SELECT subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subconflictlogdest | subconflictlogrelid
+--------------------+---------------------
+ log | 0
+(1 row)
+
+-- verify the physical table is gone
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test2';
+ count
+-------
+ 0
+(1 row)
+
+--
+-- PUBLICATION: Verify conflict log tables are not publishable
+--
+-- pg_relation_is_publishable should return false for internal conflict log
+-- tables to prevent them from being accidentally included in publications
+--
+SELECT n.nspname, pg_relation_is_publishable(c.oid)
+FROM pg_class c
+JOIN pg_namespace n ON c.relnamespace = n.oid
+JOIN pg_subscription s ON s.subconflictlogrelid = c.oid
+WHERE s.subname = 'regress_conflict_test1';
+ nspname | pg_relation_is_publishable
+-------------+----------------------------
+ pg_conflict | f
+(1 row)
+
+--
+-- Table Protection and Lifecycle Management
+--
+-- These tests verify that:
+-- Manual DROP TABLE is disallowed
+-- DROP SUBSCRIPTION automatically reaps the table
+--
+-- re-enable table logging for verification
+ALTER SUBSCRIPTION regress_conflict_test1 SET (conflict_log_destination = 'table');
+-- We use a DO block with dynamic SQL because the internal conflict log table
+-- name contains the subscription OID, which is non-deterministic. This
+-- approach allows us to attempt the DROP and capture the expected error
+-- without hard-coding a specific OID in the expected output
+-- fail - drop table not allowed due to internal dependency
+DO $$
+BEGIN
+ EXECUTE 'DROP TABLE ' || (SELECT 'pg_conflict.pg_conflict_' || oid FROM pg_subscription WHERE subname = 'regress_conflict_test1');
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege';
+END $$;
+NOTICE: captured expected error: insufficient_privilege
+-- CLEANUP: DROP SUBSCRIPTION reaps the table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+-- Verify the table OID for reap check
+SELECT 'pg_conflict_' || oid AS internal_tablename FROM pg_subscription WHERE subname = 'regress_conflict_test1' \gset
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the conflict log table was reaped via dependency
+SELECT to_regclass(:'internal_tablename');
+ to_regclass
+-------------
+
+(1 row)
+
+--
+-- Additional Namespace and Table Protection Tests
+--
+-- Setup: Ensure we have a subscription with a conflict log table
+CREATE SUBSCRIPTION regress_conflict_protection_test CONNECTION 'dbname=regress_doesnotexist'
+ PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- Trying to ALTER the internal conflict log table
+-- This should fail because the table is system-managed
+-- As mentioned in previous test cases, we use a DO block to hide dynamic OIDs
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ RAISE NOTICE 'Attempting ALTER TABLE on internal conflict log table';
+ EXECUTE 'ALTER TABLE ' || tab_name || ' ADD COLUMN extra_info text';
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during ALTER';
+END $$;
+NOTICE: Attempting ALTER TABLE on internal conflict log table
+NOTICE: captured expected error: insufficient_privilege during ALTER
+-- Test Manual INSERT on conflict log table
+-- This should fail because the table is system-managed
+-- Hiding the OID in the error message by catching the exception
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ EXECUTE 'INSERT INTO ' || tab_name || ' (relname) VALUES (''mytest'')';
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during INSERT';
+END $$;
+NOTICE: captured expected error: insufficient_privilege during INSERT
+-- Test Manual UPDATE on conflict log table
+-- This should fail because the table is system-managed
+-- Hiding the OID in the error message by catching the exception
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ EXECUTE 'UPDATE ' || tab_name || ' SET relname = ''mytest'' ';
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during UPDATE';
+END $$;
+NOTICE: captured expected error: insufficient_privilege during UPDATE
+-- Trying to perform TRUNCATE/DELETE on the internal conflict log table
+-- This should be allowed so that user can perform cleanup
+SELECT 'pg_conflict.' || relname AS conflict_tab
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_protection_test' \gset
+TRUNCATE :conflict_tab;
+DELETE FROM :conflict_tab;
+-- Trying to create a new table manually in the pg_conflict namespace
+-- This should fail as the namespace is reserved for conflict logs tables
+CREATE TABLE pg_conflict.manual_table (id int);
+ERROR: permission denied to create "pg_conflict.manual_table"
+DETAIL: System catalog modifications are currently disallowed.
+-- Moving an existing table into the pg_conflict namespace
+-- Users should not be able to move their own tables within this namespace
+CREATE TABLE public.test_move (id int);
+ALTER TABLE public.test_move SET SCHEMA pg_conflict;
+ERROR: cannot move objects into or out of CONFLICT schema
+DROP TABLE public.test_move;
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_log_default DISABLE;
+ALTER SUBSCRIPTION regress_conflict_log_default SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_log_default;
+ALTER SUBSCRIPTION regress_conflict_empty_str DISABLE;
+ALTER SUBSCRIPTION regress_conflict_empty_str SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_empty_str;
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+ALTER SUBSCRIPTION regress_conflict_protection_test DISABLE;
+ALTER SUBSCRIPTION regress_conflict_protection_test SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_protection_test;
RESET SESSION AUTHORIZATION;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index ef0c298d2df..d2934478392 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -365,6 +365,223 @@ COMMIT;
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG DESTINATION TESTS
+--
+
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+
+-- fail - unrecognized parameter value
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
+
+-- verify subconflictlogdest is 'log' and relid is 0 (InvalidOid) for default case
+CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
+
+-- verify empty string defaults to 'log'
+CREATE SUBSCRIPTION regress_conflict_empty_str CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = '');
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
+
+-- this should generate an internal conflict log table named pg_conflict_$subid$
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+
+-- check metadata in pg_subscription: destination should be 'table' and relid valid
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- verify the physical table exists, its OID matches subconflictlogrelid,
+-- and it is located in the 'pg_conflict' namespace
+SELECT n.nspname, (c.oid = s.subconflictlogrelid) AS "oid_matches"
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+JOIN pg_namespace n ON c.relnamespace = n.oid
+WHERE s.subname = 'regress_conflict_test1';
+
+-- check if the conflict log table has the correct schema
+SELECT a.attnum, a.attname
+FROM pg_attribute a
+JOIN pg_class c ON a.attrelid = c.oid
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0
+ ORDER BY a.attnum;
+
+--
+-- ALTER SUBSCRIPTION - conflict_log_destination state transitions
+--
+-- These tests verify the transition logic between different logging
+-- destinations, ensuring conflict log tables are created or dropped as
+-- expected
+--
+-- transition from 'log' to 'all'
+-- a new internal conflict log table should be created
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
+
+-- verify metadata after ALTER (destination should be 'all')
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'all' to 'table'
+-- should NOT drop the table, only change destination string
+SELECT subconflictlogrelid AS old_relid FROM pg_subscription WHERE subname = 'regress_conflict_test2' \gset
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'table');
+SELECT subconflictlogdest, subconflictlogrelid = :old_relid AS relid_unchanged
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'table' to 'log'
+-- should drop the table and clear relid
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log');
+SELECT subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- verify the physical table is gone
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test2';
+
+--
+-- PUBLICATION: Verify conflict log tables are not publishable
+--
+-- pg_relation_is_publishable should return false for internal conflict log
+-- tables to prevent them from being accidentally included in publications
+--
+SELECT n.nspname, pg_relation_is_publishable(c.oid)
+FROM pg_class c
+JOIN pg_namespace n ON c.relnamespace = n.oid
+JOIN pg_subscription s ON s.subconflictlogrelid = c.oid
+WHERE s.subname = 'regress_conflict_test1';
+
+--
+-- Table Protection and Lifecycle Management
+--
+-- These tests verify that:
+-- Manual DROP TABLE is disallowed
+-- DROP SUBSCRIPTION automatically reaps the table
+--
+-- re-enable table logging for verification
+ALTER SUBSCRIPTION regress_conflict_test1 SET (conflict_log_destination = 'table');
+
+-- We use a DO block with dynamic SQL because the internal conflict log table
+-- name contains the subscription OID, which is non-deterministic. This
+-- approach allows us to attempt the DROP and capture the expected error
+-- without hard-coding a specific OID in the expected output
+
+-- fail - drop table not allowed due to internal dependency
+DO $$
+BEGIN
+ EXECUTE 'DROP TABLE ' || (SELECT 'pg_conflict.pg_conflict_' || oid FROM pg_subscription WHERE subname = 'regress_conflict_test1');
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege';
+END $$;
+
+-- CLEANUP: DROP SUBSCRIPTION reaps the table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+
+-- Verify the table OID for reap check
+SELECT 'pg_conflict_' || oid AS internal_tablename FROM pg_subscription WHERE subname = 'regress_conflict_test1' \gset
+
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the conflict log table was reaped via dependency
+SELECT to_regclass(:'internal_tablename');
+
+--
+-- Additional Namespace and Table Protection Tests
+--
+
+-- Setup: Ensure we have a subscription with a conflict log table
+CREATE SUBSCRIPTION regress_conflict_protection_test CONNECTION 'dbname=regress_doesnotexist'
+ PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+
+-- Trying to ALTER the internal conflict log table
+-- This should fail because the table is system-managed
+-- As mentioned in previous test cases, we use a DO block to hide dynamic OIDs
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ RAISE NOTICE 'Attempting ALTER TABLE on internal conflict log table';
+ EXECUTE 'ALTER TABLE ' || tab_name || ' ADD COLUMN extra_info text';
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during ALTER';
+END $$;
+
+-- Test Manual INSERT on conflict log table
+-- This should fail because the table is system-managed
+-- Hiding the OID in the error message by catching the exception
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ EXECUTE 'INSERT INTO ' || tab_name || ' (relname) VALUES (''mytest'')';
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during INSERT';
+END $$;
+
+-- Test Manual UPDATE on conflict log table
+-- This should fail because the table is system-managed
+-- Hiding the OID in the error message by catching the exception
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ EXECUTE 'UPDATE ' || tab_name || ' SET relname = ''mytest'' ';
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during UPDATE';
+END $$;
+
+-- Trying to perform TRUNCATE/DELETE on the internal conflict log table
+-- This should be allowed so that user can perform cleanup
+SELECT 'pg_conflict.' || relname AS conflict_tab
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_protection_test' \gset
+TRUNCATE :conflict_tab;
+DELETE FROM :conflict_tab;
+
+-- Trying to create a new table manually in the pg_conflict namespace
+-- This should fail as the namespace is reserved for conflict logs tables
+CREATE TABLE pg_conflict.manual_table (id int);
+
+-- Moving an existing table into the pg_conflict namespace
+-- Users should not be able to move their own tables within this namespace
+CREATE TABLE public.test_move (id int);
+ALTER TABLE public.test_move SET SCHEMA pg_conflict;
+DROP TABLE public.test_move;
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_log_default DISABLE;
+ALTER SUBSCRIPTION regress_conflict_log_default SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_log_default;
+
+ALTER SUBSCRIPTION regress_conflict_empty_str DISABLE;
+ALTER SUBSCRIPTION regress_conflict_empty_str SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_empty_str;
+
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+
+ALTER SUBSCRIPTION regress_conflict_protection_test DISABLE;
+ALTER SUBSCRIPTION regress_conflict_protection_test SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_protection_test;
+
RESET SESSION AUTHORIZATION;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 14dec2d49c1..60644734ed6 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -501,6 +501,8 @@ ConditionVariableMinimallyPadded
ConditionalStack
ConfigData
ConfigVariable
+ConflictLogColumnDef
+ConflictLogDest
ConflictTupleInfo
ConflictType
ConnCacheEntry
--
2.49.0
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-19 02:27 Peter Smith <smithpb2250@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
2 siblings, 1 reply; 912+ messages in thread
From: Peter Smith @ 2026-01-19 02:27 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; vignesh C <vignesh21@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Some review comments for v22-0001
======
src/backend/executor/execMain.c
1.
+ /*
+ * Conflict logging tables (CLT) are managed by the system to record
+ * replication conflicts. We allow DELETE to permit users to manually prune
+ * or truncate these logs, but manual data insertion or modification
+ * (INSERT, UPDATE, MERGE) is prohibited to maintain the integrity of the
+ * system-generated logs.
+ */
+ if (IsConflictNamespace(RelationGetNamespace(resultRel)) &&
+ operation != CMD_DELETE)
+ ereport(ERROR,
+ (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+ errmsg("cannot execute %s on conflict logging table \"%s\"",
+ (operation == CMD_INSERT ? "INSERT" :
+ operation == CMD_UPDATE ? "UPDATE" :
+ operation == CMD_MERGE ? "MERGE" : "this operation"),
+ RelationGetRelationName(resultRel)),
+ errdetail("Conflict logging tables are system-managed and only
support cleanup via DELETE or TRUNCATE.")));
1a.
For consistency with every other place, "Conflict logging table"
should be changed to "Conflict log table". (See comment, errmsg,
errdetail).
~
1b.
Naming the failed operation seemed unnecessary. Also, the way it is
currently implemented, having "this operation" substitution looks like
bad practice for translations. Consider just a simpler message.
SUGGESTION
cannot modify or insert data for conflict log table \"%s\"
======
src/bin/psql/describe.c
2.
+ appendPQExpBuffer(&buf,
+ ", (CASE WHEN subconflictlogdest IN ('table', 'all') "
+ " THEN 'pg_conflict.pg_conflict_' || oid "
+ " ELSE '-' END) AS \"%s\"\n",
+ gettext_noop("Conflict log table"));
I'm not sure if you really needed to include the 'pg_conflict' schema
name here. It's just taking up space, and it's already well-defined in
the documentation that CLT only live in the 'pg_conflict' schema.
======
Kind Regards,
Peter Smith.
Fujitsu Australia
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-19 03:22 Peter Smith <smithpb2250@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
2 siblings, 0 replies; 912+ messages in thread
From: Peter Smith @ 2026-01-19 03:22 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; vignesh C <vignesh21@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Some review comments for v22-0002
======
1. Nit. "JSON" versus "json" capitalisation
Whichever way you choose, the patch should have consistent
capitalisation in all your comments.
Sometimes UPPERCASE
-------------------
single JSON array in the local_conflicts column.
The JSON array uses the following structured format:
+/* Schema for the elements within the 'local_conflicts' JSON array */
+ /* Convert to a JSON datum. */
+ * Builds the local conflicts JSON array column from the list of
+ /* Process local conflict tuple list and prepare an array of JSON. */
+ /* Convert conflicting tuple to JSON datum. */
+ * Build the higher level JSON datum in format described in function
+# Verify that '2' is present inside the JSON structure using a regex
+# Verify that '6' is present inside the JSON structure using a regex
Sometimes lowercase
-------------------
+ * Fetch replica identity key from the tuple table slot and convert into a
+ * json datum.
+ List *json_datums = NIL; /* List to hold the row_to_json results
(type json) */
+ /* Construct the json[] array Datum. */
+ * json datum from key value. Otherwise, construct it from the complete
======
src/backend/replication/logical/conflict.c
tuple_table_slot_to_json_datum:
2.
+ * Helper function to convert a TupleTableSlot to Jsonb.
This 'Jsonb' comment was accidentally missed when you changed all
previous jsonb to json?
======
Kind Regards,
Peter Smith.
Fujitsu Australia
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-19 04:11 Peter Smith <smithpb2250@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
2 siblings, 1 reply; 912+ messages in thread
From: Peter Smith @ 2026-01-19 04:11 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; vignesh C <vignesh21@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Some review comments for v22-0003.
======
1.
It looks like none of my previous v20-0003 review comments [1] have
been addressed. Maybe accidentally overlooked?
======
2.
+ <caution>
+ <para>
+ The internal conflict logging table is strictly tied to
the lifecycle of the
+ subscription or the
<literal>conflict_log_destination</literal> setting. If
+ the subscription is dropped, or if the destination is changed to
+ <literal>log</literal>, the table and all its recorded
conflict data are
+ <emphasis>permanently deleted</emphasis>. To perform a
post-mortem analysis
+ after removing a subscription, users must manually back up
or rename the
+ conflict table before the deletion occurs.
+ </para>
+ </caution>
2a.
Let's consistently call this the "Conflict log table", same as
everywhere else, not "logging table".
~
2b.
This is only a caution for the CLT, so I felt it's better to put this
in the scope of the 'table' param value.
~~~
3.
+ analysis of conflicts. This table is automatically
dropped when the
+ subscription is removed.
If you move the <caution> to this scope, as suggested above in #2b,
then you can remove the sentence "This table is automatically dropped
when the subscription is removed", because that is duplicate
information you already wrote in the caution.
======
[1] v20 docs review -
https://www.postgresql.org/message-id/CAHut%2BPuzB4gNYvqX9hb28KE0RK_xhU%2B2-%3DwUfL5OEVUCi92Hqw%40ma...
Kind Regards,
Peter Smith.
Fujitsu Australia
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-19 04:18 Dilip Kumar <dilipbalaut@gmail.com>
parent: Peter Smith <smithpb2250@gmail.com>
0 siblings, 0 replies; 912+ messages in thread
From: Dilip Kumar @ 2026-01-19 04:18 UTC (permalink / raw)
To: Peter Smith <smithpb2250@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; vignesh C <vignesh21@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Mon, Jan 19, 2026 at 7:57 AM Peter Smith <smithpb2250@gmail.com> wrote:
>
> Some review comments for v22-0001
>
> ======
> src/backend/executor/execMain.c
>
> 1.
> + /*
> + * Conflict logging tables (CLT) are managed by the system to record
> + * replication conflicts. We allow DELETE to permit users to manually prune
> + * or truncate these logs, but manual data insertion or modification
> + * (INSERT, UPDATE, MERGE) is prohibited to maintain the integrity of the
> + * system-generated logs.
> + */
> + if (IsConflictNamespace(RelationGetNamespace(resultRel)) &&
> + operation != CMD_DELETE)
> + ereport(ERROR,
> + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
> + errmsg("cannot execute %s on conflict logging table \"%s\"",
> + (operation == CMD_INSERT ? "INSERT" :
> + operation == CMD_UPDATE ? "UPDATE" :
> + operation == CMD_MERGE ? "MERGE" : "this operation"),
> + RelationGetRelationName(resultRel)),
> + errdetail("Conflict logging tables are system-managed and only
> support cleanup via DELETE or TRUNCATE.")));
>
> 1a.
> For consistency with every other place, "Conflict logging table"
> should be changed to "Conflict log table". (See comment, errmsg,
> errdetail).
>
> ~
>
> 1b.
> Naming the failed operation seemed unnecessary. Also, the way it is
> currently implemented, having "this operation" substitution looks like
> bad practice for translations. Consider just a simpler message.
>
> SUGGESTION
> cannot modify or insert data for conflict log table \"%s\"
Make sense.
> ======
> src/bin/psql/describe.c
>
> 2.
> + appendPQExpBuffer(&buf,
> + ", (CASE WHEN subconflictlogdest IN ('table', 'all') "
> + " THEN 'pg_conflict.pg_conflict_' || oid "
> + " ELSE '-' END) AS \"%s\"\n",
> + gettext_noop("Conflict log table"));
>
> I'm not sure if you really needed to include the 'pg_conflict' schema
> name here. It's just taking up space, and it's already well-defined in
> the documentation that CLT only live in the 'pg_conflict' schema.
Right, we can remove the schema name from here.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-19 04:52 vignesh C <vignesh21@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 0 replies; 912+ messages in thread
From: vignesh C @ 2026-01-19 04:52 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Mon, 12 Jan 2026 at 17:18, Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Mon, Jan 12, 2026 at 7:51 AM Amit Kapila <amit.kapila16@gmail.com> wrote:
> >
> > On Fri, Jan 9, 2026 at 7:13 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > >
> > > On Fri, Jan 9, 2026 at 12:42 PM vignesh C <vignesh21@gmail.com> wrote:
> > > >
> > > > On Mon, 5 Jan 2026 at 15:13, Dilip Kumar <dilipbalaut@gmail.com> wrote:
> > > > >
> > > > > Here is the updated version of patch
> > > >
> > > > I would like to suggest renaming the newly introduced system schema
> > > > from pg_conflict to pg_logical or something in lines of logical
> > > > replication.
> > > > Although the immediate use case is conflict logging, the schema is
> > > > fundamentally part of the logical replication subsystem. A broader
> > > > name such as pg_logical provides a more appropriate and future proof
> > > > namespace. In contrast, a feature-specific name like pg_conflict is
> > > > risky as logical replication evolves and could necessitate the
> > > > introduction of additional system schemas later. Looking ahead,
> > > > advanced features such as DDL replication or multi-node writes would
> > > > likely require metadata for cluster topology of multiple nodes, leader
> > > > state, peer discovery, and resolution policies for node failure. This
> > > > type of replication specific metadata would naturally fit under a
> > > > pg_logical system schema rather than pg_catalog.
> > > >
> > > > Finally, adopting pg_logical would leave open the possibility of
> > > > logically grouping existing logical replication catalogs and views
> > > > (publications, subscriptions, and slot related information) under a
> > > > single subsystem namespace, instead of having them scattered across
> > > > pg_catalog.
> > >
> > > I agree with this analysis of making it future proof what others think
> > > about this? Although we might not be clear now what permission
> > > differences would be needed for future metadata tables compared to
> > > conflict tables, those could be managed if we get the generic name.
> > >
> >
> > Why do you think this additional meta-data can't reside in the
> > pg_catalog schema as we have for other system tables? IIUC, we are
> > planning to have a pg_conflict schema for the conflict history table
> > because we want a different treatment for pg_dump (like we want to
> > dump its data during upgrade) and some permissions for user access to
> > it (say for purging data from this table).
>
> Yeah that make sense.
>
> AFAICS, the other meta-data
> > mentioned above shouldn't have any such specific needs unless I am
> > missing something. It is not that I am against naming it differently
> > or using some generic name but I can't see a reason for it. Instead,
> > following a path like we already have for pg_toast (where schema name
> > indicates its purpose) sounds like a better fit.
>
> So maybe we should continue with the pg_conflict name itself. Lets
> see what Vignesh has to say as he has raised this concern.
My intent in raising the point was mainly to sanity check the naming,
given that we generally avoid introducing new system schemas, and
since we are doing so here, I wanted to ensure the name is reasonably
future proof. I agree that the immediate motivation for a separate
schema is driven by the specific requirements for conflict history,
and in that context pg_conflict is a clear and purpose driven name,
similar to how pg_toast reflects its use case. Looking ahead, it
seems reasonable that future features such as DDL replication would
either place their metadata in pg_catalog, with conflict related
information continuing to live in pg_conflict, or otherwise fit
naturally within this split. I couldn't identify a concrete future use
case that would not fit well into pg_catalog and/or pg_conflict. Given
that, I’m fine with proceeding with the pg_conflict naming.
Regards,
Vignesh
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-19 05:26 Dilip Kumar <dilipbalaut@gmail.com>
parent: Peter Smith <smithpb2250@gmail.com>
0 siblings, 2 replies; 912+ messages in thread
From: Dilip Kumar @ 2026-01-19 05:26 UTC (permalink / raw)
To: Peter Smith <smithpb2250@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; vignesh C <vignesh21@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Mon, Jan 19, 2026 at 9:42 AM Peter Smith <smithpb2250@gmail.com> wrote:
>
> Some review comments for v22-0003.
>
> ======
>
> 1.
> It looks like none of my previous v20-0003 review comments [1] have
> been addressed. Maybe accidentally overlooked?
>
> ======
>
> 2.
> + <caution>
> + <para>
> + The internal conflict logging table is strictly tied to
> the lifecycle of the
> + subscription or the
> <literal>conflict_log_destination</literal> setting. If
> + the subscription is dropped, or if the destination is changed to
> + <literal>log</literal>, the table and all its recorded
> conflict data are
> + <emphasis>permanently deleted</emphasis>. To perform a
> post-mortem analysis
> + after removing a subscription, users must manually back up
> or rename the
> + conflict table before the deletion occurs.
> + </para>
> + </caution>
>
> 2a.
> Let's consistently call this the "Conflict log table", same as
> everywhere else, not "logging table".
>
> ~
>
> 2b.
> This is only a caution for the CLT, so I felt it's better to put this
> in the scope of the 'table' param value.
>
> ~~~
>
> 3.
> + analysis of conflicts. This table is automatically
> dropped when the
> + subscription is removed.
>
> If you move the <caution> to this scope, as suggested above in #2b,
> then you can remove the sentence "This table is automatically dropped
> when the subscription is removed", because that is duplicate
> information you already wrote in the caution.
The attached patch fixes above comments and other comments reported in
v22-0001 and v22-0002
> ======
> [1] v20 docs review -
> https://www.postgresql.org/message-id/CAHut%2BPuzB4gNYvqX9hb28KE0RK_xhU%2B2-%3DwUfL5OEVUCi92Hqw%40ma...
I think I missed them, so I will respond to them separately.
--
Regards,
Dilip Kumar
Google
Attachments:
[application/octet-stream] v23-0001-Add-configurable-conflict-log-table-for-Logical-.patch (111.6K, ../../CAFiTN-v9i9RmDvdUmtMUow4=b+nr0k7LKMyEQ+6ZF=EVdfBhBA@mail.gmail.com/2-v23-0001-Add-configurable-conflict-log-table-for-Logical-.patch)
download | inline diff:
From 71ccecd7bcb5fbea2fe282bc2d461e739b5a6cb4 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 17 Dec 2025 11:53:47 +0530
Subject: [PATCH v23 1/3] Add configurable conflict log table for Logical
Replication
This patch adds a feature to provide a structured, queryable record of all
logical replication conflicts. The current approach of logging conflicts as
plain text in the server logs makes it difficult to query, analyze, and
use for external monitoring and automation.
This patch addresses these limitations by introducing a configurable
conflict_log_destination=('log'/'table'/'all') option in the CREATE SUBSCRIPTION
command.
If the user chooses to enable logging to a table (by selecting 'table' or 'all'),
an internal logging table named conflict_log_table_<subid> is automatically
created within a dedicated, system-managed namespace named pg_conflict. This table
is linked to the subscription via an internal dependency, ensuring it is
automatically dropped when the subscription is removed.
The conflict details, including the original and remote tuples, are stored in JSON
columns, providing a flexible format to accommodate different table schemas.
The log table captures essential attributes such as local and remote transaction IDs,
LSNs, commit timestamps, and conflict type, providing a complete record for post-mortem
analysis.
This feature will make logical replication conflicts easier to monitor and manage,
significantly improving the overall resilience and operability of replication setups.
The conflict log tables will not be included in a publication, even if the publication
is configured to include ALL TABLES or ALL TABLES IN SCHEMA.
---
src/backend/catalog/catalog.c | 27 +-
src/backend/catalog/heap.c | 3 +-
src/backend/catalog/namespace.c | 8 +-
src/backend/catalog/pg_publication.c | 14 +-
src/backend/catalog/pg_subscription.c | 7 +
src/backend/commands/subscriptioncmds.c | 239 ++++++++++-
src/backend/commands/tablecmds.c | 6 +-
src/backend/executor/execMain.c | 15 +
src/bin/psql/describe.c | 16 +-
src/bin/psql/tab-complete.in.c | 8 +-
src/include/catalog/catalog.h | 2 +
src/include/catalog/pg_namespace.dat | 3 +
src/include/catalog/pg_subscription.h | 11 +
src/include/commands/subscriptioncmds.h | 3 +
src/include/replication/conflict.h | 55 +++
src/test/regress/expected/subscription.out | 444 +++++++++++++++++----
src/test/regress/sql/subscription.sql | 217 ++++++++++
src/tools/pgindent/typedefs.list | 2 +
18 files changed, 978 insertions(+), 102 deletions(-)
diff --git a/src/backend/catalog/catalog.c b/src/backend/catalog/catalog.c
index 7be49032934..d438dc682ec 100644
--- a/src/backend/catalog/catalog.c
+++ b/src/backend/catalog/catalog.c
@@ -86,7 +86,8 @@ bool
IsSystemClass(Oid relid, Form_pg_class reltuple)
{
/* IsCatalogRelationOid is a bit faster, so test that first */
- return (IsCatalogRelationOid(relid) || IsToastClass(reltuple));
+ return (IsCatalogRelationOid(relid) || IsToastClass(reltuple)
+ || IsConflictClass(reltuple));
}
/*
@@ -230,6 +231,18 @@ IsToastClass(Form_pg_class reltuple)
return IsToastNamespace(relnamespace);
}
+/*
+ * IsConflictClass - Check if the given pg_class tuple belongs to the conflict
+ * namespace.
+ */
+bool
+IsConflictClass(Form_pg_class reltuple)
+{
+ Oid relnamespace = reltuple->relnamespace;
+
+ return IsConflictNamespace(relnamespace);
+}
+
/*
* IsCatalogNamespace
* True iff namespace is pg_catalog.
@@ -264,6 +277,18 @@ IsToastNamespace(Oid namespaceId)
isTempToastNamespace(namespaceId);
}
+/*
+ * IsConflictNamespace
+ * True iff namespace is pg_conflict.
+ *
+ * Does not perform any catalog accesses.
+ */
+bool
+IsConflictNamespace(Oid namespaceId)
+{
+ return namespaceId == PG_CONFLICT_NAMESPACE;
+}
+
/*
* IsReservedName
diff --git a/src/backend/catalog/heap.c b/src/backend/catalog/heap.c
index 606434823cf..10dadf378a4 100644
--- a/src/backend/catalog/heap.c
+++ b/src/backend/catalog/heap.c
@@ -314,7 +314,8 @@ heap_create(const char *relname,
*/
if (!allow_system_table_mods &&
((IsCatalogNamespace(relnamespace) && relkind != RELKIND_INDEX) ||
- IsToastNamespace(relnamespace)) &&
+ IsToastNamespace(relnamespace) ||
+ IsConflictNamespace(relnamespace)) &&
IsNormalProcessingMode())
ereport(ERROR,
(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
diff --git a/src/backend/catalog/namespace.c b/src/backend/catalog/namespace.c
index c3b79a2ba48..400292fd06b 100644
--- a/src/backend/catalog/namespace.c
+++ b/src/backend/catalog/namespace.c
@@ -3523,7 +3523,7 @@ LookupCreationNamespace(const char *nspname)
*
* We complain if either the old or new namespaces is a temporary schema
* (or temporary toast schema), or if either the old or new namespaces is the
- * TOAST schema.
+ * TOAST schema or the CONFLICT schema.
*/
void
CheckSetNamespace(Oid oldNspOid, Oid nspOid)
@@ -3539,6 +3539,12 @@ CheckSetNamespace(Oid oldNspOid, Oid nspOid)
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("cannot move objects into or out of TOAST schema")));
+
+ /* similarly for CONFLICT schema */
+ if (nspOid == PG_CONFLICT_NAMESPACE || oldNspOid == PG_CONFLICT_NAMESPACE)
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot move objects into or out of CONFLICT schema")));
}
/*
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index 9a4791c573e..a33c33efe0d 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -31,6 +31,7 @@
#include "catalog/pg_publication_rel.h"
#include "catalog/pg_type.h"
#include "commands/publicationcmds.h"
+#include "commands/subscriptioncmds.h"
#include "funcapi.h"
#include "utils/array.h"
#include "utils/builtins.h"
@@ -85,6 +86,15 @@ check_publication_add_relation(Relation targetrel)
errmsg("cannot add relation \"%s\" to publication",
RelationGetRelationName(targetrel)),
errdetail("This operation is not supported for unlogged tables.")));
+
+ /* Can't be conflict log table */
+ if (IsConflictNamespace(RelationGetNamespace(targetrel)))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot add relation \"%s.%s\" to publication",
+ get_namespace_name(RelationGetNamespace(targetrel)),
+ RelationGetRelationName(targetrel)),
+ errdetail("This operation is not supported for conflict log tables.")));
}
/*
@@ -95,7 +105,8 @@ static void
check_publication_add_schema(Oid schemaid)
{
/* Can't be system namespace */
- if (IsCatalogNamespace(schemaid) || IsToastNamespace(schemaid))
+ if (IsCatalogNamespace(schemaid) || IsToastNamespace(schemaid) ||
+ IsConflictNamespace(schemaid))
ereport(ERROR,
(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
errmsg("cannot add schema \"%s\" to publication",
@@ -139,6 +150,7 @@ is_publishable_class(Oid relid, Form_pg_class reltuple)
reltuple->relkind == RELKIND_PARTITIONED_TABLE ||
reltuple->relkind == RELKIND_SEQUENCE) &&
!IsCatalogRelationOid(relid) &&
+ !IsConflictClass(reltuple) &&
reltuple->relpersistence == RELPERSISTENCE_PERMANENT &&
relid >= FirstNormalObjectId;
}
diff --git a/src/backend/catalog/pg_subscription.c b/src/backend/catalog/pg_subscription.c
index 2b103245290..285a598497d 100644
--- a/src/backend/catalog/pg_subscription.c
+++ b/src/backend/catalog/pg_subscription.c
@@ -106,6 +106,7 @@ GetSubscription(Oid subid, bool missing_ok)
sub->retaindeadtuples = subform->subretaindeadtuples;
sub->maxretention = subform->submaxretention;
sub->retentionactive = subform->subretentionactive;
+ sub->conflictlogrelid = subform->subconflictlogrelid;
/* Get conninfo */
datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
@@ -141,6 +142,12 @@ GetSubscription(Oid subid, bool missing_ok)
Anum_pg_subscription_suborigin);
sub->origin = TextDatumGetCString(datum);
+ /* Get conflict log destination */
+ datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
+ tup,
+ Anum_pg_subscription_subconflictlogdest);
+ sub->conflictlogdest = TextDatumGetCString(datum);
+
/* Is the subscription owner a superuser? */
sub->ownersuperuser = superuser_arg(sub->owner);
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index d6674f20fc2..de1977f81ef 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -15,25 +15,31 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/htup_details.h"
#include "access/table.h"
#include "access/twophase.h"
#include "access/xact.h"
#include "catalog/catalog.h"
#include "catalog/dependency.h"
+#include "catalog/heap.h"
#include "catalog/indexing.h"
#include "catalog/namespace.h"
#include "catalog/objectaccess.h"
#include "catalog/objectaddress.h"
+#include "catalog/pg_am_d.h"
#include "catalog/pg_authid_d.h"
#include "catalog/pg_database_d.h"
+#include "catalog/pg_namespace.h"
#include "catalog/pg_subscription.h"
#include "catalog/pg_subscription_rel.h"
#include "catalog/pg_type.h"
+#include "commands/comment.h"
#include "commands/defrem.h"
#include "commands/event_trigger.h"
#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "miscadmin.h"
#include "nodes/makefuncs.h"
#include "pgstat.h"
@@ -51,6 +57,7 @@
#include "utils/lsyscache.h"
#include "utils/memutils.h"
#include "utils/pg_lsn.h"
+#include "utils/regproc.h"
#include "utils/syscache.h"
/*
@@ -75,6 +82,7 @@
#define SUBOPT_MAX_RETENTION_DURATION 0x00008000
#define SUBOPT_LSN 0x00010000
#define SUBOPT_ORIGIN 0x00020000
+#define SUBOPT_CONFLICT_LOG_DEST 0x00040000
/* check if the 'val' has 'bits' set */
#define IsSet(val, bits) (((val) & (bits)) == (bits))
@@ -103,6 +111,7 @@ typedef struct SubOpts
bool retaindeadtuples;
int32 maxretention;
char *origin;
+ ConflictLogDest conflictlogdest;
XLogRecPtr lsn;
} SubOpts;
@@ -135,7 +144,7 @@ static List *merge_publications(List *oldpublist, List *newpublist, bool addpub,
static void ReportSlotConnectionError(List *rstates, Oid subid, char *slotname, char *err);
static void CheckAlterSubOption(Subscription *sub, const char *option,
bool slot_needs_update, bool isTopLevel);
-
+static Oid create_conflict_log_table(Oid subid, char *subname);
/*
* Common option parsing function for CREATE and ALTER SUBSCRIPTION commands.
@@ -191,6 +200,8 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->maxretention = 0;
if (IsSet(supported_opts, SUBOPT_ORIGIN))
opts->origin = pstrdup(LOGICALREP_ORIGIN_ANY);
+ if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DEST))
+ opts->conflictlogdest = CONFLICT_LOG_DEST_LOG;
/* Parse options */
foreach(lc, stmt_options)
@@ -402,6 +413,18 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->specified_opts |= SUBOPT_LSN;
opts->lsn = lsn;
}
+ else if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DEST) &&
+ strcmp(defel->defname, "conflict_log_destination") == 0)
+ {
+ char *val;
+
+ if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_LOG_DEST))
+ errorConflictingDefElem(defel, pstate);
+
+ val = defGetString(defel);
+ opts->conflictlogdest = GetLogDestination(val);
+ opts->specified_opts |= SUBOPT_CONFLICT_LOG_DEST;
+ }
else
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
@@ -599,6 +622,7 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bits32 supported_opts;
SubOpts opts = {0};
AclResult aclresult;
+ Oid logrelid = InvalidOid;
/*
* Parse and check options.
@@ -612,7 +636,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
SUBOPT_DISABLE_ON_ERR | SUBOPT_PASSWORD_REQUIRED |
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
- SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN);
+ SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_DEST);
parse_subscription_options(pstate, stmt->options, supported_opts, &opts);
/*
@@ -747,6 +772,18 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
values[Anum_pg_subscription_suborigin - 1] =
CStringGetTextDatum(opts.origin);
+ /* Always set the destination, default will be 'log'. */
+ values[Anum_pg_subscription_subconflictlogdest - 1] =
+ CStringGetTextDatum(ConflictLogDestNames[opts.conflictlogdest]);
+
+ /* If logging to a table is required, physically create the table. */
+ if (IsSet(opts.conflictlogdest, CONFLICT_LOG_DEST_TABLE))
+ logrelid = create_conflict_log_table(subid, stmt->subname);
+
+ /* Store table OID in the catalog. */
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(logrelid);
+
tup = heap_form_tuple(RelationGetDescr(rel), values, nulls);
/* Insert tuple into catalog. */
@@ -1410,7 +1447,8 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
SUBOPT_MAX_RETENTION_DURATION |
- SUBOPT_ORIGIN);
+ SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_DEST);
parse_subscription_options(pstate, stmt->options,
supported_opts, &opts);
@@ -1665,6 +1703,63 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
origin = opts.origin;
}
+ if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_LOG_DEST))
+ {
+ ConflictLogDest old_dest =
+ GetLogDestination(sub->conflictlogdest);
+
+ if (opts.conflictlogdest != old_dest)
+ {
+ bool want_table = IsSet(opts.conflictlogdest,
+ CONFLICT_LOG_DEST_TABLE);
+ bool has_oldtable =
+ IsSet(old_dest, CONFLICT_LOG_DEST_TABLE);
+
+ values[Anum_pg_subscription_subconflictlogdest - 1] =
+ CStringGetTextDatum(ConflictLogDestNames[opts.conflictlogdest]);
+ replaces[Anum_pg_subscription_subconflictlogdest - 1] = true;
+
+ if (want_table && !has_oldtable)
+ {
+ Oid relid;
+
+ relid = create_conflict_log_table(subid, sub->name);
+
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(relid);
+ replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
+ true;
+ }
+ else if (!want_table && has_oldtable)
+ {
+ ObjectAddress object;
+
+ /*
+ * Conflict log tables are recorded as internal
+ * dependencies of the subscription. Drop the
+ * table if it is not required anymore to avoid
+ * stale or orphaned relations.
+ *
+ * XXX: At present, only conflict log tables are
+ * managed this way. In future if we introduce
+ * additional internal dependencies, we may need
+ * a targeted deletion to avoid deletion of any
+ * other objects.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId,
+ subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(InvalidOid);
+ replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
+ true;
+ }
+ }
+ }
+
update_tuple = true;
break;
}
@@ -2027,6 +2122,7 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
Form_pg_subscription form;
List *rstates;
bool must_use_password;
+ ObjectAddress object;
/*
* The launcher may concurrently start a new worker for this subscription.
@@ -2184,6 +2280,19 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
/* Clean up dependencies */
deleteSharedDependencyRecordsFor(SubscriptionRelationId, subid, 0);
+ /*
+ * Conflict log tables are recorded as internal dependencies of the
+ * subscription. We must drop the dependent objects before the
+ * subscription itself is removed. By using
+ * PERFORM_DELETION_SKIP_ORIGINAL, we ensure that only the conflict log
+ * table is reaped while the subscription remains for the final deletion
+ * step.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId, subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
/* Remove any associated relation synchronization states. */
RemoveSubscriptionRel(subid, InvalidOid);
@@ -3190,3 +3299,127 @@ defGetStreamingMode(DefElem *def)
def->defname)));
return LOGICALREP_STREAM_OFF; /* keep compiler quiet */
}
+
+/*
+ * Builds the TupleDesc for the conflict log table.
+ */
+static TupleDesc
+create_conflict_log_table_tupdesc(void)
+{
+ TupleDesc tupdesc;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_CONFLICT_ATTR_NUM);
+
+ for (int i = 0; i < MAX_CONFLICT_ATTR_NUM; i++)
+ {
+ Oid type_oid = ConflictLogSchema[i].atttypid;
+
+ /*
+ * Special handling for the JSON array type for proper
+ * TupleDescInitEntry call.
+ */
+ if (type_oid == JSONARRAYOID)
+ type_oid = get_array_type(JSONOID);
+
+ TupleDescInitEntry(tupdesc, i + 1,
+ ConflictLogSchema[i].attname,
+ type_oid,
+ -1, 0);
+ }
+
+ return BlessTupleDesc(tupdesc);
+}
+
+/*
+ * Create a structured conflict log table for a subscription.
+ *
+ * The table is created within the system-managed 'pg_conflict' namespace.
+ * The table name is generated automatically using the subscription's OID
+ * (e.g., "pg_conflict_<subid>") to ensure uniqueness within the cluster and
+ * to avoid collisions during subscription renames.
+ */
+static Oid
+create_conflict_log_table(Oid subid, char *subname)
+{
+ TupleDesc tupdesc;
+ Oid relid;
+ ObjectAddress myself;
+ ObjectAddress subaddr;
+ char relname[NAMEDATALEN];
+
+ snprintf(relname, NAMEDATALEN, "pg_conflict_%u", subid);
+
+ /* There can not be an existing table with the same name. */
+ Assert(!OidIsValid(get_relname_relid(relname, PG_CONFLICT_NAMESPACE)));
+
+ /* Build the tuple descriptor for the new table. */
+ tupdesc = create_conflict_log_table_tupdesc();
+
+ /* Create conflict log table. */
+ relid = heap_create_with_catalog(relname,
+ PG_CONFLICT_NAMESPACE,
+ 0, /* tablespace */
+ InvalidOid, /* relid */
+ InvalidOid, /* reltypeid */
+ InvalidOid, /* reloftypeid */
+ GetUserId(),
+ HEAP_TABLE_AM_OID,
+ tupdesc,
+ NIL,
+ RELKIND_RELATION,
+ RELPERSISTENCE_PERMANENT,
+ false, /* shared_relation */
+ false, /* mapped_relation */
+ ONCOMMIT_NOOP,
+ (Datum) 0, /* reloptions */
+ false, /* use_user_acl */
+ true, /* allow_system_table_mods */
+ true, /* is_internal */
+ InvalidOid, /* relrewrite */
+ NULL); /* typaddress */
+
+ /*
+ * Establish an internal dependency between the conflict log table and
+ * the subscription.
+ *
+ * We use DEPENDENCY_INTERNAL to signify that the table's lifecycle is
+ * strictly tied to the subscription, similar to how a TOAST table relates
+ * to its main table or a sequence relates to an identity column.
+ *
+ * This ensures the conflict log table is automatically reaped during a
+ * DROP SUBSCRIPTION via performDeletion().
+ */
+ ObjectAddressSet(myself, RelationRelationId, relid);
+ ObjectAddressSet(subaddr, SubscriptionRelationId, subid);
+ recordDependencyOn(&myself, &subaddr, DEPENDENCY_INTERNAL);
+
+ /* Release tuple descriptor memory. */
+ FreeTupleDesc(tupdesc);
+
+ return relid;
+}
+
+/*
+ * GetLogDestination
+ *
+ * Convert string to enum by comparing against standardized labels.
+ */
+ConflictLogDest
+GetLogDestination(const char *dest)
+{
+ /* Empty string or NULL defaults to LOG. */
+ if (dest == NULL || dest[0] == '\0' || pg_strcasecmp(dest, "log") == 0)
+ return CONFLICT_LOG_DEST_LOG;
+
+ if (pg_strcasecmp(dest, "table") == 0)
+ return CONFLICT_LOG_DEST_TABLE;
+
+ if (pg_strcasecmp(dest, "all") == 0)
+ return CONFLICT_LOG_DEST_ALL;
+
+ /* Unrecognized string. */
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("unrecognized conflict_log_destination value: \"%s\"", dest),
+ errhint("Valid values are \"log\", \"table\", and \"all\".")));
+}
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index f976c0e5c7e..4bf39f359d7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -2398,9 +2398,11 @@ truncate_check_rel(Oid relid, Form_pg_class reltuple)
* pg_largeobject and pg_largeobject_metadata to be truncated as part of
* pg_upgrade, because we need to change its relfilenode to match the old
* cluster, and allowing a TRUNCATE command to be executed is the easiest
- * way of doing that.
+ * way of doing that. We also allow TRUNCATE on the conflict log tables,
+ * to permit users to manually prune these logs to manage disk space.
*/
- if (!allowSystemTableMods && IsSystemClass(relid, reltuple)
+ if (!allowSystemTableMods && IsSystemClass(relid, reltuple) &&
+ !IsConflictClass(reltuple)
&& (!IsBinaryUpgrade ||
(relid != LargeObjectRelationId &&
relid != LargeObjectMetadataRelationId)))
diff --git a/src/backend/executor/execMain.c b/src/backend/executor/execMain.c
index bfd3ebc601e..971b75a596f 100644
--- a/src/backend/executor/execMain.c
+++ b/src/backend/executor/execMain.c
@@ -1170,6 +1170,21 @@ CheckValidResultRel(ResultRelInfo *resultRelInfo, CmdType operation,
RelationGetRelationName(resultRel))));
break;
}
+
+ /*
+ * Conflict log tables are managed by the system to record logical
+ * replication conflicts. We allow DELETE to permit users to manually
+ * prune or truncate these logs, but manual data insertion or modification
+ * (INSERT, UPDATE, MERGE) is prohibited to maintain the integrity of the
+ * system-generated logs.
+ */
+ if (IsConflictNamespace(RelationGetNamespace(resultRel)) &&
+ operation != CMD_DELETE)
+ ereport(ERROR,
+ (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+ errmsg("cannot modify or insert data for conflict log table \"%s\"",
+ RelationGetRelationName(resultRel)),
+ errdetail("Conflict log tables are system-managed and only support cleanup via DELETE or TRUNCATE.")));
}
/*
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index 3584c4e1428..21cfe390430 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -6806,7 +6806,7 @@ describeSubscriptions(const char *pattern, bool verbose)
printQueryOpt myopt = pset.popt;
static const bool translate_columns[] = {false, false, false, false,
false, false, false, false, false, false, false, false, false, false,
- false, false, false, false};
+ false, false, false, false, false, false};
if (pset.sversion < 100000)
{
@@ -6900,6 +6900,20 @@ describeSubscriptions(const char *pattern, bool verbose)
appendPQExpBuffer(&buf,
", subskiplsn AS \"%s\"\n",
gettext_noop("Skip LSN"));
+
+ /* Conflict log destination is supported in v19 and higher */
+ if (pset.sversion >= 190000)
+ {
+ appendPQExpBuffer(&buf,
+ ", subconflictlogdest AS \"%s\"\n",
+ gettext_noop("Conflict log destination"));
+
+ appendPQExpBuffer(&buf,
+ ", (CASE WHEN subconflictlogdest IN ('table', 'all') "
+ " THEN 'pg_conflict_' || oid "
+ " ELSE '-' END) AS \"%s\"\n",
+ gettext_noop("Conflict log table"));
+ }
}
/* Only display subscriptions in current database. */
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 8b91bc00062..12eee8a0d43 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -2344,8 +2344,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("(", "PUBLICATION");
/* ALTER SUBSCRIPTION <name> SET ( */
else if (Matches("ALTER", "SUBSCRIPTION", MatchAny, MatchAnyN, "SET", "("))
- COMPLETE_WITH("binary", "disable_on_error", "failover",
- "max_retention_duration", "origin",
+ COMPLETE_WITH("binary", "conflict_log_destination", "disable_on_error",
+ "failover", "max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
"synchronous_commit", "two_phase");
@@ -3860,8 +3860,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("WITH (");
/* Complete "CREATE SUBSCRIPTION <name> ... WITH ( <opt>" */
else if (Matches("CREATE", "SUBSCRIPTION", MatchAnyN, "WITH", "("))
- COMPLETE_WITH("binary", "connect", "copy_data", "create_slot",
- "disable_on_error", "enabled", "failover",
+ COMPLETE_WITH("binary", "conflict_log_destination", "connect", "copy_data",
+ "create_slot", "disable_on_error", "enabled", "failover",
"max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
diff --git a/src/include/catalog/catalog.h b/src/include/catalog/catalog.h
index a9d6e8ea986..8193229f2e2 100644
--- a/src/include/catalog/catalog.h
+++ b/src/include/catalog/catalog.h
@@ -25,6 +25,7 @@ extern bool IsInplaceUpdateRelation(Relation relation);
extern bool IsSystemClass(Oid relid, Form_pg_class reltuple);
extern bool IsToastClass(Form_pg_class reltuple);
+extern bool IsConflictClass(Form_pg_class reltuple);
extern bool IsCatalogRelationOid(Oid relid);
extern bool IsCatalogTextUniqueIndexOid(Oid relid);
@@ -32,6 +33,7 @@ extern bool IsInplaceUpdateOid(Oid relid);
extern bool IsCatalogNamespace(Oid namespaceId);
extern bool IsToastNamespace(Oid namespaceId);
+extern bool IsConflictNamespace(Oid namespaceId);
extern bool IsReservedName(const char *name);
diff --git a/src/include/catalog/pg_namespace.dat b/src/include/catalog/pg_namespace.dat
index 3075e142c73..b45cb9383a8 100644
--- a/src/include/catalog/pg_namespace.dat
+++ b/src/include/catalog/pg_namespace.dat
@@ -18,6 +18,9 @@
{ oid => '99', oid_symbol => 'PG_TOAST_NAMESPACE',
descr => 'reserved schema for TOAST tables',
nspname => 'pg_toast', nspacl => '_null_' },
+{ oid => '1382', oid_symbol => 'PG_CONFLICT_NAMESPACE',
+ descr => 'reserved schema for subscription-specific conflict log tables',
+ nspname => 'pg_conflict', nspacl => '_null_' },
# update dumpNamespace() if changing this descr
{ oid => '2200', oid_symbol => 'PG_PUBLIC_NAMESPACE',
descr => 'standard public schema',
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index f3571d2bfcf..76a4638b389 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -90,6 +90,7 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
* exceeded max_retention_duration, when
* defined */
+ Oid subconflictlogrelid; /* Relid of the conflict log table. */
#ifdef CATALOG_VARLEN /* variable-length fields start here */
/* Connection string to the publisher */
text subconninfo BKI_FORCE_NOT_NULL;
@@ -103,6 +104,14 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
/* List of publications subscribed to */
text subpublications[1] BKI_FORCE_NOT_NULL;
+ /*
+ * Strategy for logging replication conflicts:
+ * 'log' - server log only,
+ * 'table' - conflict log table only,
+ * 'all' - both log and table.
+ */
+ text subconflictlogdest;
+
/* Only publish data originating from the specified origin */
text suborigin BKI_DEFAULT(LOGICALREP_ORIGIN_ANY);
#endif
@@ -152,12 +161,14 @@ typedef struct Subscription
* and the retention duration has not
* exceeded max_retention_duration, when
* defined */
+ Oid conflictlogrelid; /* conflict log table Oid */
char *conninfo; /* Connection string to the publisher */
char *slotname; /* Name of the replication slot */
char *synccommit; /* Synchronous commit setting for worker */
List *publications; /* List of publication names to subscribe to */
char *origin; /* Only publish data originating from the
* specified origin */
+ char *conflictlogdest; /* Conflict log destination */
} Subscription;
#ifdef EXPOSE_TO_CLIENT_CODE
diff --git a/src/include/commands/subscriptioncmds.h b/src/include/commands/subscriptioncmds.h
index 63504232a14..a895127f8fe 100644
--- a/src/include/commands/subscriptioncmds.h
+++ b/src/include/commands/subscriptioncmds.h
@@ -17,6 +17,7 @@
#include "catalog/objectaddress.h"
#include "parser/parse_node.h"
+#include "replication/conflict.h"
extern ObjectAddress CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bool isTopLevel);
@@ -36,4 +37,6 @@ extern void CheckSubDeadTupleRetention(bool check_guc, bool sub_disabled,
bool retention_active,
bool max_retention_set);
+extern ConflictLogDest GetLogDestination(const char *dest);
+
#endif /* SUBSCRIPTIONCMDS_H */
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index d538274637f..4e4f59bb453 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -10,6 +10,7 @@
#define CONFLICT_H
#include "access/xlogdefs.h"
+#include "catalog/pg_type.h"
#include "nodes/pg_list.h"
#include "utils/timestamp.h"
@@ -79,6 +80,60 @@ typedef struct ConflictTupleInfo
* conflicting local row occurred */
} ConflictTupleInfo;
+/*
+ * Conflict log destination types.
+ *
+ * These values are defined as bitmask flags to allow for multiple simultaneous
+ * logging destinations (e.g., logging to both system logs and a table).
+ * Internally, we use these for bitwise comparisons (IsSet), but the string
+ * representation is stored in pg_subscription.subconflictlogdest.
+ */
+typedef enum ConflictLogDest
+{
+ /* Log conflicts to the server logs */
+ CONFLICT_LOG_DEST_LOG = 1 << 0, /* 0x01 */
+
+ /* Log conflicts to an internally managed conflict log table */
+ CONFLICT_LOG_DEST_TABLE = 1 << 1, /* 0x02 */
+
+ /* Convenience bitmask for all supported destinations */
+ CONFLICT_LOG_DEST_ALL = (CONFLICT_LOG_DEST_LOG | CONFLICT_LOG_DEST_TABLE)
+} ConflictLogDest;
+
+/*
+ * Array mapping for converting internal enum to string.
+ */
+static const char *const ConflictLogDestNames[] = {
+ [CONFLICT_LOG_DEST_LOG] = "log",
+ [CONFLICT_LOG_DEST_TABLE] = "table",
+ [CONFLICT_LOG_DEST_ALL] = "all"
+};
+
+/* Structure to hold metadata for one column of the conflict log table */
+typedef struct ConflictLogColumnDef
+{
+ const char *attname; /* Column name */
+ Oid atttypid; /* Data type OID */
+} ConflictLogColumnDef;
+
+/* The single source of truth for the conflict log table schema */
+static const ConflictLogColumnDef ConflictLogSchema[] =
+{
+ { .attname = "relid", .atttypid = OIDOID },
+ { .attname = "schemaname", .atttypid = TEXTOID },
+ { .attname = "relname", .atttypid = TEXTOID },
+ { .attname = "conflict_type", .atttypid = TEXTOID },
+ { .attname = "remote_xid", .atttypid = XIDOID },
+ { .attname = "remote_commit_lsn",.atttypid = LSNOID },
+ { .attname = "remote_commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "remote_origin", .atttypid = TEXTOID },
+ { .attname = "replica_identity", .atttypid = JSONOID },
+ { .attname = "remote_tuple", .atttypid = JSONOID },
+ { .attname = "local_conflicts", .atttypid = JSONARRAYOID }
+};
+
+#define MAX_CONFLICT_ATTR_NUM lengthof(ConflictLogSchema)
+
extern bool GetTupleTransactionInfo(TupleTableSlot *localslot,
TransactionId *xmin,
RepOriginId *localorigin,
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index b3eccd8afe3..b94bcc3cc23 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -116,18 +116,18 @@ CREATE SUBSCRIPTION regress_testsub4 CONNECTION 'dbname=regress_doesnotexist' PU
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub4 SET (origin = any);
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
DROP SUBSCRIPTION regress_testsub3;
@@ -145,10 +145,10 @@ ALTER SUBSCRIPTION regress_testsub CONNECTION 'foobar';
ERROR: invalid connection string syntax: missing "=" after "foobar" in connection info string
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET PUBLICATION testpub2, testpub3 WITH (refresh = false);
@@ -157,10 +157,10 @@ ALTER SUBSCRIPTION regress_testsub SET (slot_name = 'newname');
ALTER SUBSCRIPTION regress_testsub SET (password_required = false);
ALTER SUBSCRIPTION regress_testsub SET (run_as_owner = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (password_required = true);
@@ -176,10 +176,10 @@ ERROR: unrecognized subscription parameter: "create_slot"
-- ok
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/12345');
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345 | log | -
(1 row)
-- ok - with lsn = NONE
@@ -188,10 +188,10 @@ ALTER SUBSCRIPTION regress_testsub SKIP (lsn = NONE);
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/0');
ERROR: invalid WAL location (LSN): 0/0
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log | -
(1 row)
BEGIN;
@@ -223,10 +223,10 @@ ALTER SUBSCRIPTION regress_testsub_foo SET (synchronous_commit = foobar);
ERROR: invalid value for parameter "synchronous_commit": "foobar"
HINT: Available values: local, remote_write, remote_apply, on, off.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
----------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+---------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------+--------------------
+ regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000 | log | -
(1 row)
-- rename back to keep the rest simple
@@ -255,19 +255,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (binary = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -279,27 +279,27 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = parallel);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
-- fail - publication already exists
@@ -314,10 +314,10 @@ ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refr
ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refresh = false);
ERROR: publication "testpub1" is already in subscription "regress_testsub"
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
-- fail - publication used more than once
@@ -332,10 +332,10 @@ ERROR: publication "testpub3" is not in subscription "regress_testsub"
-- ok - delete publications
ALTER SUBSCRIPTION regress_testsub DROP PUBLICATION testpub1, testpub2 WITH (refresh = false);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -371,19 +371,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
-- we can alter streaming when two_phase enabled
ALTER SUBSCRIPTION regress_testsub SET (streaming = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -393,10 +393,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -409,18 +409,18 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (disable_on_error = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -433,10 +433,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -450,19 +450,19 @@ NOTICE: max_retention_duration is ineffective when retain_dead_tuples is disabl
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
-- ok
ALTER SUBSCRIPTION regress_testsub SET (max_retention_duration = 0);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -517,6 +517,274 @@ COMMIT;
-- ok, owning it is enough for this stuff
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG DESTINATION TESTS
+--
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+-- fail - unrecognized parameter value
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
+ERROR: unrecognized conflict_log_destination value: "invalid"
+HINT: Valid values are "log", "table", and "all".
+-- verify subconflictlogdest is 'log' and relid is 0 (InvalidOid) for default case
+CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
+ subname | subconflictlogdest | subconflictlogrelid
+------------------------------+--------------------+---------------------
+ regress_conflict_log_default | log | 0
+(1 row)
+
+-- verify empty string defaults to 'log'
+CREATE SUBSCRIPTION regress_conflict_empty_str CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = '');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
+ subname | subconflictlogdest | subconflictlogrelid
+----------------------------+--------------------+---------------------
+ regress_conflict_empty_str | log | 0
+(1 row)
+
+-- this should generate an internal conflict log table named pg_conflict_$subid$
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- check metadata in pg_subscription: destination should be 'table' and relid valid
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname | subconflictlogdest | has_relid
+------------------------+--------------------+-----------
+ regress_conflict_test1 | table | t
+(1 row)
+
+-- verify the physical table exists, its OID matches subconflictlogrelid,
+-- and it is located in the 'pg_conflict' namespace
+SELECT n.nspname, (c.oid = s.subconflictlogrelid) AS "oid_matches"
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+JOIN pg_namespace n ON c.relnamespace = n.oid
+WHERE s.subname = 'regress_conflict_test1';
+ nspname | oid_matches
+-------------+-------------
+ pg_conflict | t
+(1 row)
+
+-- check if the conflict log table has the correct schema
+SELECT a.attnum, a.attname
+FROM pg_attribute a
+JOIN pg_class c ON a.attrelid = c.oid
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0
+ ORDER BY a.attnum;
+ attnum | attname
+--------+-------------------
+ 1 | relid
+ 2 | schemaname
+ 3 | relname
+ 4 | conflict_type
+ 5 | remote_xid
+ 6 | remote_commit_lsn
+ 7 | remote_commit_ts
+ 8 | remote_origin
+ 9 | replica_identity
+ 10 | remote_tuple
+ 11 | local_conflicts
+(11 rows)
+
+--
+-- ALTER SUBSCRIPTION - conflict_log_destination state transitions
+--
+-- These tests verify the transition logic between different logging
+-- destinations, ensuring conflict log tables are created or dropped as
+-- expected
+--
+-- transition from 'log' to 'all'
+-- a new internal conflict log table should be created
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
+-- verify metadata after ALTER (destination should be 'all')
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogdest | has_relid
+------------------------+--------------------+-----------
+ regress_conflict_test2 | all | t
+(1 row)
+
+-- transition from 'all' to 'table'
+-- should NOT drop the table, only change destination string
+SELECT subconflictlogrelid AS old_relid FROM pg_subscription WHERE subname = 'regress_conflict_test2' \gset
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'table');
+SELECT subconflictlogdest, subconflictlogrelid = :old_relid AS relid_unchanged
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subconflictlogdest | relid_unchanged
+--------------------+-----------------
+ table | t
+(1 row)
+
+-- transition from 'table' to 'log'
+-- should drop the table and clear relid
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log');
+SELECT subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subconflictlogdest | subconflictlogrelid
+--------------------+---------------------
+ log | 0
+(1 row)
+
+-- verify the physical table is gone
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test2';
+ count
+-------
+ 0
+(1 row)
+
+--
+-- PUBLICATION: Verify conflict log tables are not publishable
+--
+-- pg_relation_is_publishable should return false for internal conflict log
+-- tables to prevent them from being accidentally included in publications
+--
+SELECT n.nspname, pg_relation_is_publishable(c.oid)
+FROM pg_class c
+JOIN pg_namespace n ON c.relnamespace = n.oid
+JOIN pg_subscription s ON s.subconflictlogrelid = c.oid
+WHERE s.subname = 'regress_conflict_test1';
+ nspname | pg_relation_is_publishable
+-------------+----------------------------
+ pg_conflict | f
+(1 row)
+
+--
+-- Table Protection and Lifecycle Management
+--
+-- These tests verify that:
+-- Manual DROP TABLE is disallowed
+-- DROP SUBSCRIPTION automatically reaps the table
+--
+-- re-enable table logging for verification
+ALTER SUBSCRIPTION regress_conflict_test1 SET (conflict_log_destination = 'table');
+-- We use a DO block with dynamic SQL because the internal conflict log table
+-- name contains the subscription OID, which is non-deterministic. This
+-- approach allows us to attempt the DROP and capture the expected error
+-- without hard-coding a specific OID in the expected output
+-- fail - drop table not allowed due to internal dependency
+DO $$
+BEGIN
+ EXECUTE 'DROP TABLE ' || (SELECT 'pg_conflict.pg_conflict_' || oid FROM pg_subscription WHERE subname = 'regress_conflict_test1');
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege';
+END $$;
+NOTICE: captured expected error: insufficient_privilege
+-- CLEANUP: DROP SUBSCRIPTION reaps the table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+-- Verify the table OID for reap check
+SELECT 'pg_conflict_' || oid AS internal_tablename FROM pg_subscription WHERE subname = 'regress_conflict_test1' \gset
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the conflict log table was reaped via dependency
+SELECT to_regclass(:'internal_tablename');
+ to_regclass
+-------------
+
+(1 row)
+
+--
+-- Additional Namespace and Table Protection Tests
+--
+-- Setup: Ensure we have a subscription with a conflict log table
+CREATE SUBSCRIPTION regress_conflict_protection_test CONNECTION 'dbname=regress_doesnotexist'
+ PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- Trying to ALTER the internal conflict log table
+-- This should fail because the table is system-managed
+-- As mentioned in previous test cases, we use a DO block to hide dynamic OIDs
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ RAISE NOTICE 'Attempting ALTER TABLE on internal conflict log table';
+ EXECUTE 'ALTER TABLE ' || tab_name || ' ADD COLUMN extra_info text';
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during ALTER';
+END $$;
+NOTICE: Attempting ALTER TABLE on internal conflict log table
+NOTICE: captured expected error: insufficient_privilege during ALTER
+-- Test Manual INSERT on conflict log table
+-- This should fail because the table is system-managed
+-- Hiding the OID in the error message by catching the exception
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ EXECUTE 'INSERT INTO ' || tab_name || ' (relname) VALUES (''mytest'')';
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during INSERT';
+END $$;
+NOTICE: captured expected error: insufficient_privilege during INSERT
+-- Test Manual UPDATE on conflict log table
+-- This should fail because the table is system-managed
+-- Hiding the OID in the error message by catching the exception
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ EXECUTE 'UPDATE ' || tab_name || ' SET relname = ''mytest'' ';
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during UPDATE';
+END $$;
+NOTICE: captured expected error: insufficient_privilege during UPDATE
+-- Trying to perform TRUNCATE/DELETE on the internal conflict log table
+-- This should be allowed so that user can perform cleanup
+SELECT 'pg_conflict.' || relname AS conflict_tab
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_protection_test' \gset
+TRUNCATE :conflict_tab;
+DELETE FROM :conflict_tab;
+-- Trying to create a new table manually in the pg_conflict namespace
+-- This should fail as the namespace is reserved for conflict logs tables
+CREATE TABLE pg_conflict.manual_table (id int);
+ERROR: permission denied to create "pg_conflict.manual_table"
+DETAIL: System catalog modifications are currently disallowed.
+-- Moving an existing table into the pg_conflict namespace
+-- Users should not be able to move their own tables within this namespace
+CREATE TABLE public.test_move (id int);
+ALTER TABLE public.test_move SET SCHEMA pg_conflict;
+ERROR: cannot move objects into or out of CONFLICT schema
+DROP TABLE public.test_move;
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_log_default DISABLE;
+ALTER SUBSCRIPTION regress_conflict_log_default SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_log_default;
+ALTER SUBSCRIPTION regress_conflict_empty_str DISABLE;
+ALTER SUBSCRIPTION regress_conflict_empty_str SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_empty_str;
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+ALTER SUBSCRIPTION regress_conflict_protection_test DISABLE;
+ALTER SUBSCRIPTION regress_conflict_protection_test SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_protection_test;
RESET SESSION AUTHORIZATION;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index ef0c298d2df..d2934478392 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -365,6 +365,223 @@ COMMIT;
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG DESTINATION TESTS
+--
+
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+
+-- fail - unrecognized parameter value
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
+
+-- verify subconflictlogdest is 'log' and relid is 0 (InvalidOid) for default case
+CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
+
+-- verify empty string defaults to 'log'
+CREATE SUBSCRIPTION regress_conflict_empty_str CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = '');
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
+
+-- this should generate an internal conflict log table named pg_conflict_$subid$
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+
+-- check metadata in pg_subscription: destination should be 'table' and relid valid
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- verify the physical table exists, its OID matches subconflictlogrelid,
+-- and it is located in the 'pg_conflict' namespace
+SELECT n.nspname, (c.oid = s.subconflictlogrelid) AS "oid_matches"
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+JOIN pg_namespace n ON c.relnamespace = n.oid
+WHERE s.subname = 'regress_conflict_test1';
+
+-- check if the conflict log table has the correct schema
+SELECT a.attnum, a.attname
+FROM pg_attribute a
+JOIN pg_class c ON a.attrelid = c.oid
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0
+ ORDER BY a.attnum;
+
+--
+-- ALTER SUBSCRIPTION - conflict_log_destination state transitions
+--
+-- These tests verify the transition logic between different logging
+-- destinations, ensuring conflict log tables are created or dropped as
+-- expected
+--
+-- transition from 'log' to 'all'
+-- a new internal conflict log table should be created
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
+
+-- verify metadata after ALTER (destination should be 'all')
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'all' to 'table'
+-- should NOT drop the table, only change destination string
+SELECT subconflictlogrelid AS old_relid FROM pg_subscription WHERE subname = 'regress_conflict_test2' \gset
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'table');
+SELECT subconflictlogdest, subconflictlogrelid = :old_relid AS relid_unchanged
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'table' to 'log'
+-- should drop the table and clear relid
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log');
+SELECT subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- verify the physical table is gone
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test2';
+
+--
+-- PUBLICATION: Verify conflict log tables are not publishable
+--
+-- pg_relation_is_publishable should return false for internal conflict log
+-- tables to prevent them from being accidentally included in publications
+--
+SELECT n.nspname, pg_relation_is_publishable(c.oid)
+FROM pg_class c
+JOIN pg_namespace n ON c.relnamespace = n.oid
+JOIN pg_subscription s ON s.subconflictlogrelid = c.oid
+WHERE s.subname = 'regress_conflict_test1';
+
+--
+-- Table Protection and Lifecycle Management
+--
+-- These tests verify that:
+-- Manual DROP TABLE is disallowed
+-- DROP SUBSCRIPTION automatically reaps the table
+--
+-- re-enable table logging for verification
+ALTER SUBSCRIPTION regress_conflict_test1 SET (conflict_log_destination = 'table');
+
+-- We use a DO block with dynamic SQL because the internal conflict log table
+-- name contains the subscription OID, which is non-deterministic. This
+-- approach allows us to attempt the DROP and capture the expected error
+-- without hard-coding a specific OID in the expected output
+
+-- fail - drop table not allowed due to internal dependency
+DO $$
+BEGIN
+ EXECUTE 'DROP TABLE ' || (SELECT 'pg_conflict.pg_conflict_' || oid FROM pg_subscription WHERE subname = 'regress_conflict_test1');
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege';
+END $$;
+
+-- CLEANUP: DROP SUBSCRIPTION reaps the table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+
+-- Verify the table OID for reap check
+SELECT 'pg_conflict_' || oid AS internal_tablename FROM pg_subscription WHERE subname = 'regress_conflict_test1' \gset
+
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the conflict log table was reaped via dependency
+SELECT to_regclass(:'internal_tablename');
+
+--
+-- Additional Namespace and Table Protection Tests
+--
+
+-- Setup: Ensure we have a subscription with a conflict log table
+CREATE SUBSCRIPTION regress_conflict_protection_test CONNECTION 'dbname=regress_doesnotexist'
+ PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+
+-- Trying to ALTER the internal conflict log table
+-- This should fail because the table is system-managed
+-- As mentioned in previous test cases, we use a DO block to hide dynamic OIDs
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ RAISE NOTICE 'Attempting ALTER TABLE on internal conflict log table';
+ EXECUTE 'ALTER TABLE ' || tab_name || ' ADD COLUMN extra_info text';
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during ALTER';
+END $$;
+
+-- Test Manual INSERT on conflict log table
+-- This should fail because the table is system-managed
+-- Hiding the OID in the error message by catching the exception
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ EXECUTE 'INSERT INTO ' || tab_name || ' (relname) VALUES (''mytest'')';
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during INSERT';
+END $$;
+
+-- Test Manual UPDATE on conflict log table
+-- This should fail because the table is system-managed
+-- Hiding the OID in the error message by catching the exception
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ EXECUTE 'UPDATE ' || tab_name || ' SET relname = ''mytest'' ';
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during UPDATE';
+END $$;
+
+-- Trying to perform TRUNCATE/DELETE on the internal conflict log table
+-- This should be allowed so that user can perform cleanup
+SELECT 'pg_conflict.' || relname AS conflict_tab
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_protection_test' \gset
+TRUNCATE :conflict_tab;
+DELETE FROM :conflict_tab;
+
+-- Trying to create a new table manually in the pg_conflict namespace
+-- This should fail as the namespace is reserved for conflict logs tables
+CREATE TABLE pg_conflict.manual_table (id int);
+
+-- Moving an existing table into the pg_conflict namespace
+-- Users should not be able to move their own tables within this namespace
+CREATE TABLE public.test_move (id int);
+ALTER TABLE public.test_move SET SCHEMA pg_conflict;
+DROP TABLE public.test_move;
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_log_default DISABLE;
+ALTER SUBSCRIPTION regress_conflict_log_default SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_log_default;
+
+ALTER SUBSCRIPTION regress_conflict_empty_str DISABLE;
+ALTER SUBSCRIPTION regress_conflict_empty_str SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_empty_str;
+
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+
+ALTER SUBSCRIPTION regress_conflict_protection_test DISABLE;
+ALTER SUBSCRIPTION regress_conflict_protection_test SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_protection_test;
+
RESET SESSION AUTHORIZATION;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 3f3a888fd0e..e11873ab931 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -502,6 +502,8 @@ ConditionVariableMinimallyPadded
ConditionalStack
ConfigData
ConfigVariable
+ConflictLogColumnDef
+ConflictLogDest
ConflictTupleInfo
ConflictType
ConnCacheEntry
--
2.49.0
[application/octet-stream] v23-0002-Implement-the-conflict-insertion-infrastructure-.patch (28.6K, ../../CAFiTN-v9i9RmDvdUmtMUow4=b+nr0k7LKMyEQ+6ZF=EVdfBhBA@mail.gmail.com/3-v23-0002-Implement-the-conflict-insertion-infrastructure-.patch)
download | inline diff:
From 1d5b648a36087044f377e99eb091ac181b1baa75 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Mon, 12 Jan 2026 20:21:03 +0530
Subject: [PATCH v23 2/3] Implement the conflict insertion infrastructure for
the conflict log table
This patch introduces the core logic to populate the conflict log table whenever
a logical replication conflict is detected. It captures the remote transaction
details along with the corresponding local state at the time of the conflict.
Handling Multi-row Conflicts: A single remote tuple may conflict with multiple
local tuples (e.g., in the case of multiple_unique_conflicts). To handle this,
the infrastructure creates a single row in the conflict log table for each
remote tuple. The details of all conflicting local rows are aggregated into a
single JSON array in the local_conflicts column.
The JSON array uses the following structured format:
[ { "xid": "1001", "commit_ts": "2025-12-25 10:00:00+05:30", "origin": "node_1",
"key": {"id": 1}, "tuple": {"id": 1, "val": "old_data"} }, ... ]
Example of querying the structured conflict data:
SELECT remote_xid, relname, remote_origin, local_conflicts[1] ->> 'xid' AS local_xid,
local_conflicts[1] ->> 'tuple' AS local_tuple
FROM myschema.conflict_log_history2;
remote_xid | relname | remote_origin | local_xid | local_tuple
------------+----------+---------------+-----------+---------------------
760 | test | pg_16406 | 771 | {"a":1,"b":10}
765 | conf_tab | pg_16406 | 775 | {"a":2,"b":2,"c":2}
---
src/backend/replication/logical/conflict.c | 561 +++++++++++++++++++--
src/backend/replication/logical/launcher.c | 1 +
src/backend/replication/logical/worker.c | 31 +-
src/include/replication/conflict.h | 3 +
src/include/replication/worker_internal.h | 7 +
src/test/subscription/t/035_conflicts.pl | 47 +-
6 files changed, 605 insertions(+), 45 deletions(-)
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 93222ee3b88..99926036825 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -15,13 +15,21 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/tableam.h"
+#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "pgstat.h"
#include "replication/conflict.h"
#include "replication/worker_internal.h"
#include "storage/lmgr.h"
+#include "utils/array.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/lsyscache.h"
+#include "utils/pg_lsn.h"
+#include "utils/json.h"
static const char *const ConflictTypeNames[] = {
[CT_INSERT_EXISTS] = "insert_exists",
@@ -34,6 +42,18 @@ static const char *const ConflictTypeNames[] = {
[CT_MULTIPLE_UNIQUE_CONFLICTS] = "multiple_unique_conflicts"
};
+/* Schema for the elements within the 'local_conflicts' JSON array */
+static const ConflictLogColumnDef LocalConflictSchema[] =
+{
+ { .attname = "xid", .atttypid = XIDOID },
+ { .attname = "commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "origin", .atttypid = TEXTOID },
+ { .attname = "key", .atttypid = JSONOID },
+ { .attname = "tuple", .atttypid = JSONOID }
+};
+
+#define MAX_LOCAL_CONFLICT_INFO_ATTRS lengthof(LocalConflictSchema)
+
static int errcode_apply_conflict(ConflictType type);
static void errdetail_apply_conflict(EState *estate,
ResultRelInfo *relinfo,
@@ -50,8 +70,27 @@ static char *build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *localslot,
TupleTableSlot *remoteslot,
Oid indexoid);
+static void build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull);
static char *build_index_value_desc(EState *estate, Relation localrel,
TupleTableSlot *slot, Oid indexoid);
+static Datum tuple_table_slot_to_json_datum(TupleTableSlot *slot);
+static Datum tuple_table_slot_to_indextup_json(EState *estate,
+ Relation localrel,
+ Oid replica_index,
+ TupleTableSlot *slot);
+static TupleDesc build_conflict_tupledesc(void);
+static Datum build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples);
+static void prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot);
/*
* Get the xmin and commit timestamp data (origin and timestamp) associated
@@ -105,30 +144,90 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
ConflictType type, TupleTableSlot *searchslot,
TupleTableSlot *remoteslot, List *conflicttuples)
{
- Relation localrel = relinfo->ri_RelationDesc;
- StringInfoData err_detail;
+ Relation localrel = relinfo->ri_RelationDesc;
+ ConflictLogDest dest;
+ Relation conflictlogrel;
+ bool log_dest_clt;
+ bool log_dest_logfile;
- initStringInfo(&err_detail);
+ pgstat_report_subscription_conflict(MySubscription->oid, type);
- /* Form errdetail message by combining conflicting tuples information. */
- foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
- errdetail_apply_conflict(estate, relinfo, type, searchslot,
- conflicttuple->slot, remoteslot,
- conflicttuple->indexoid,
- conflicttuple->xmin,
- conflicttuple->origin,
- conflicttuple->ts,
- &err_detail);
+ /*
+ * Get the conflict log destination. Also, (if there is one) return the
+ * CLT relation already opened and ready for insertion.
+ */
+ conflictlogrel = GetConflictLogDestAndTable(&dest);
- pgstat_report_subscription_conflict(MySubscription->oid, type);
+ log_dest_clt = ((dest & CONFLICT_LOG_DEST_TABLE) != 0);
+ log_dest_logfile = ((dest & CONFLICT_LOG_DEST_LOG) != 0);
+
+ /* Insert to table if requested. */
+ if (log_dest_clt)
+ {
+ Assert(conflictlogrel != NULL);
+
+ /*
+ * Prepare the conflict log tuple. If the error level is below ERROR,
+ * insert it immediately. Otherwise, defer the insertion to a new
+ * transaction after the current one aborts, ensuring the insertion of
+ * the log tuple is not rolled back.
+ */
+ prepare_conflict_log_tuple(estate,
+ relinfo->ri_RelationDesc,
+ conflictlogrel,
+ type,
+ searchslot,
+ conflicttuples,
+ remoteslot);
+ if (elevel < ERROR)
+ InsertConflictLogTuple(conflictlogrel);
+
+ if (!log_dest_logfile)
+ {
+ /*
+ * Not logging conflict details to the server log; Report the error
+ * msg but omit raw tuple data from server logs since it's already
+ * captured in the conflict log table.
+ */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail("Conflict details are logged to the conflict log table: %s",
+ RelationGetRelationName(conflictlogrel)));
+ }
+
+ table_close(conflictlogrel, RowExclusiveLock);
+ }
- ereport(elevel,
- errcode_apply_conflict(type),
- errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
- get_namespace_name(RelationGetNamespace(localrel)),
- RelationGetRelationName(localrel),
- ConflictTypeNames[type]),
- errdetail_internal("%s", err_detail.data));
+ /* Log into the server log if requested. */
+ if (log_dest_logfile)
+ {
+ StringInfoData err_detail;
+
+ initStringInfo(&err_detail);
+
+ /* Form errdetail message by combining conflicting tuples information. */
+ foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
+ errdetail_apply_conflict(estate, relinfo, type, searchslot,
+ conflicttuple->slot, remoteslot,
+ conflicttuple->indexoid,
+ conflicttuple->xmin,
+ conflicttuple->origin,
+ conflicttuple->ts,
+ &err_detail);
+
+ /* Standard reporting with full internal details. */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail_internal("%s", err_detail.data));
+ }
}
/*
@@ -162,6 +261,64 @@ InitConflictIndexes(ResultRelInfo *relInfo)
relInfo->ri_onConflictArbiterIndexes = uniqueIndexes;
}
+/*
+ * GetConflictLogDestAndTable
+ *
+ * Fetches conflict logging metadata from the cached MySubscription pointer.
+ * Sets the destination enum in *log_dest and, if applicable, opens and
+ * returns the relation handle for the conflict log table.
+ */
+Relation
+GetConflictLogDestAndTable(ConflictLogDest *log_dest)
+{
+ Oid conflictlogrelid;
+ Relation conflictlogrel = NULL;
+
+ /*
+ * Convert the text log destination to the internal enum. MySubscription
+ * already contains the data from pg_subscription.
+ */
+ *log_dest = GetLogDestination(MySubscription->conflictlogdest);
+
+ /* Quick exit if a conflict log table was not requested. */
+ if ((*log_dest & CONFLICT_LOG_DEST_TABLE) == 0)
+ return NULL;
+
+ conflictlogrelid = MySubscription->conflictlogrelid;
+
+ Assert(OidIsValid(conflictlogrelid));
+
+ conflictlogrel = table_open(conflictlogrelid, RowExclusiveLock);
+ if (conflictlogrel == NULL)
+ elog(ERROR, "could not open conflict log table (OID %u)",
+ conflictlogrelid);
+
+ return conflictlogrel;
+}
+
+/*
+ * InsertConflictLogTuple
+ *
+ * Insert conflict log tuple into the conflict log table. It uses
+ * HEAP_INSERT_NO_LOGICAL to explicitly block logical decoding of the tuple
+ * inserted into the conflict log table.
+ */
+void
+InsertConflictLogTuple(Relation conflictlogrel)
+{
+ int options = HEAP_INSERT_NO_LOGICAL;
+
+ /* A valid tuple must be prepared and stored in MyLogicalRepWorker. */
+ Assert(MyLogicalRepWorker->conflict_log_tuple != NULL);
+
+ heap_insert(conflictlogrel, MyLogicalRepWorker->conflict_log_tuple,
+ GetCurrentCommandId(true), options, NULL);
+
+ /* Free conflict log tuple. */
+ heap_freetuple(MyLogicalRepWorker->conflict_log_tuple);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+}
+
/*
* Add SQLSTATE error code to the current conflict report.
*/
@@ -472,6 +629,40 @@ build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
return tuple_value.data;
}
+/*
+ * Helper function to extract the "raw" index key Datums and their null flags
+ * from a TupleTableSlot, given an already open index descriptor.
+ * This is the reusable core logic.
+ */
+static void
+build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull)
+{
+ TupleTableSlot *tableslot = slot;
+
+ /*
+ * If the slot is a virtual slot, copy it into a heap tuple slot as
+ * FormIndexDatum only works with heap tuple slots.
+ */
+ if (TTS_IS_VIRTUAL(slot))
+ {
+ /* Slot is created within the EState's tuple table */
+ tableslot = table_slot_create(localrel, &estate->es_tupleTable);
+ tableslot = ExecCopySlot(tableslot, slot);
+ }
+
+ /*
+ * Initialize ecxt_scantuple for potential use in FormIndexDatum
+ */
+ GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+
+ /* Form the index datums */
+ FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values,
+ isnull);
+}
+
/*
* Helper functions to construct a string describing the contents of an index
* entry. See BuildIndexValueDescription for details.
@@ -487,41 +678,325 @@ build_index_value_desc(EState *estate, Relation localrel, TupleTableSlot *slot,
Relation indexDesc;
Datum values[INDEX_MAX_KEYS];
bool isnull[INDEX_MAX_KEYS];
- TupleTableSlot *tableslot = slot;
- if (!tableslot)
+ if (!slot)
return NULL;
Assert(CheckRelationOidLockedByMe(indexoid, RowExclusiveLock, true));
indexDesc = index_open(indexoid, NoLock);
- /*
- * If the slot is a virtual slot, copy it into a heap tuple slot as
- * FormIndexDatum only works with heap tuple slots.
- */
- if (TTS_IS_VIRTUAL(slot))
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+
+ index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+
+ index_close(indexDesc, NoLock);
+
+ return index_value;
+}
+
+/*
+ * tuple_table_slot_to_json_datum
+ *
+ * Helper function to convert a TupleTableSlot to JSON.
+ */
+static Datum
+tuple_table_slot_to_json_datum(TupleTableSlot *slot)
+{
+ HeapTuple tuple;
+ Datum datum;
+ Datum json;
+
+ Assert(slot != NULL);
+
+ tuple = ExecCopySlotHeapTuple(slot);
+ datum = heap_copy_tuple_as_datum(tuple, slot->tts_tupleDescriptor);
+
+ json = DirectFunctionCall1(row_to_json, datum);
+ heap_freetuple(tuple);
+
+ return json;
+}
+
+/*
+ * tuple_table_slot_to_indextup_json
+ *
+ * Fetch replica identity key from the tuple table slot and convert into a
+ * JSON datum.
+ */
+static Datum
+tuple_table_slot_to_indextup_json(EState *estate, Relation localrel,
+ Oid indexid, TupleTableSlot *slot)
+{
+ Relation indexDesc;
+ Datum values[INDEX_MAX_KEYS];
+ bool isnull[INDEX_MAX_KEYS];
+ HeapTuple tuple;
+ TupleDesc tupdesc;
+ Datum datum;
+
+ Assert(slot != NULL);
+
+ Assert(CheckRelationOidLockedByMe(indexid, RowExclusiveLock, true));
+
+ indexDesc = index_open(indexid, NoLock);
+
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+ tupdesc = RelationGetDescr(indexDesc);
+
+ /* Bless the tupdesc so it can be looked up by row_to_json. */
+ BlessTupleDesc(tupdesc);
+
+ /* Form the replica identity tuple. */
+ tuple = heap_form_tuple(tupdesc, values, isnull);
+ datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ index_close(indexDesc, NoLock);
+ heap_freetuple(tuple);
+
+ /* Convert to a JSON datum. */
+ return DirectFunctionCall1(row_to_json, datum);
+}
+
+/*
+ * build_conflict_tupledesc
+ *
+ * Build and bless a tuple descriptor for the internal conflict log table
+ * based on the predefined LocalConflictSchema.
+ */
+static TupleDesc
+build_conflict_tupledesc(void)
+{
+ TupleDesc tupdesc;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ for (int i = 0; i < MAX_LOCAL_CONFLICT_INFO_ATTRS; i++)
+ TupleDescInitEntry(tupdesc, (AttrNumber) (i + 1),
+ LocalConflictSchema[i].attname,
+ LocalConflictSchema[i].atttypid,
+ -1, 0);
+
+ BlessTupleDesc(tupdesc);
+
+ return tupdesc;
+}
+
+/*
+ * Builds the local conflicts JSON array column from the list of
+ * ConflictTupleInfo objects.
+ *
+ * Example output structure:
+ * [ { "xid": "1001", "commit_ts": "...", "origin": "...", "tuple": {...} }, ... ]
+ */
+static Datum
+build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples)
+{
+ ListCell *lc;
+ List *json_datums = NIL;
+ Datum *json_datum_array;
+ bool *json_null_array;
+ Datum json_array_datum;
+ int num_conflicts;
+ int i;
+ int16 typlen;
+ bool typbyval;
+ char typalign;
+ TupleDesc tupdesc;
+
+ /* Build local conflicts tuple descriptor. */
+ tupdesc = build_conflict_tupledesc();
+
+ /* Process local conflict tuple list and prepare an array of JSON. */
+ foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
{
- tableslot = table_slot_create(localrel, &estate->es_tupleTable);
- tableslot = ExecCopySlot(tableslot, slot);
+ Datum values[MAX_LOCAL_CONFLICT_INFO_ATTRS] = {0};
+ bool nulls[MAX_LOCAL_CONFLICT_INFO_ATTRS] = {0};
+ char *origin_name = NULL;
+ HeapTuple tuple;
+ Datum json_datum;
+ int attno;
+
+ attno = 0;
+ values[attno++] = TransactionIdGetDatum(conflicttuple->xmin);
+
+ if (conflicttuple->ts)
+ values[attno++] = TimestampTzGetDatum(conflicttuple->ts);
+ else
+ nulls[attno++] = true;
+
+ if (conflicttuple->origin != InvalidRepOriginId)
+ replorigin_by_oid(conflicttuple->origin, true, &origin_name);
+
+ /* Store empty string if origin name for the tuple is NULL. */
+ if (origin_name != NULL)
+ values[attno++] = CStringGetTextDatum(origin_name);
+ else
+ nulls[attno++] = true;
+
+ /*
+ * Add the conflicting key values in the case of a unique constraint
+ * violation.
+ */
+ if (conflict_type == CT_INSERT_EXISTS ||
+ conflict_type == CT_UPDATE_EXISTS ||
+ conflict_type == CT_MULTIPLE_UNIQUE_CONFLICTS)
+ {
+ Oid indexoid = conflicttuple->indexoid;
+
+ Assert(OidIsValid(indexoid) && conflicttuple->slot &&
+ CheckRelationOidLockedByMe(indexoid, RowExclusiveLock,
+ true));
+ values[attno++] =
+ tuple_table_slot_to_indextup_json(estate, rel,
+ indexoid,
+ conflicttuple->slot);
+ }
+ else
+ nulls[attno++] = true;
+
+ /* Convert conflicting tuple to JSON datum. */
+ if (conflicttuple->slot)
+ values[attno] = tuple_table_slot_to_json_datum(conflicttuple->slot);
+ else
+ nulls[attno] = true;
+
+ Assert(attno + 1 == MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ tuple = heap_form_tuple(tupdesc, values, nulls);
+
+ json_datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ /*
+ * Build the higher level JSON datum in format described in function
+ * header.
+ */
+ json_datum = DirectFunctionCall1(row_to_json, json_datum);
+
+ /* Done with the temporary tuple. */
+ heap_freetuple(tuple);
+
+ /* Add to the array element. */
+ json_datums = lappend(json_datums, (void *) json_datum);
}
- /*
- * Initialize ecxt_scantuple for potential use in FormIndexDatum when
- * index expressions are present.
- */
- GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+ num_conflicts = list_length(json_datums);
- /*
- * The values/nulls arrays passed to BuildIndexValueDescription should be
- * the results of FormIndexDatum, which are the "raw" input to the index
- * AM.
- */
- FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values, isnull);
+ json_datum_array = palloc_array(Datum, num_conflicts);
+ json_null_array = palloc0_array(bool, num_conflicts);
- index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+ i = 0;
+ foreach(lc, json_datums)
+ {
+ json_datum_array[i] = (Datum) lfirst(lc);
+ i++;
+ }
- index_close(indexDesc, NoLock);
+ /* Construct the JSON array Datum. */
+ get_typlenbyvalalign(JSONOID, &typlen, &typbyval, &typalign);
+ json_array_datum = PointerGetDatum(construct_array(json_datum_array,
+ num_conflicts,
+ JSONOID,
+ typlen,
+ typbyval,
+ typalign));
+ pfree(json_datum_array);
+ pfree(json_null_array);
+
+ return json_array_datum;
+}
- return index_value;
+/*
+ * prepare_conflict_log_tuple
+ *
+ * This routine prepares a tuple detailing a conflict encountered during
+ * logical replication. The prepared tuple will be stored in
+ * MyLogicalRepWorker->conflict_log_tuple which should be inserted into the
+ * conflict log table by calling InsertConflictLogTuple.
+ */
+static void
+prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot)
+{
+ Datum values[MAX_CONFLICT_ATTR_NUM] = {0};
+ bool nulls[MAX_CONFLICT_ATTR_NUM] = {0};
+ int attno;
+ char *remote_origin = NULL;
+ MemoryContext oldctx;
+
+ Assert(MyLogicalRepWorker->conflict_log_tuple == NULL);
+
+ /* Populate the values and nulls arrays. */
+ attno = 0;
+ values[attno++] = ObjectIdGetDatum(RelationGetRelid(rel));
+
+ values[attno++] =
+ CStringGetTextDatum(get_namespace_name(RelationGetNamespace(rel)));
+
+ values[attno++] = CStringGetTextDatum(RelationGetRelationName(rel));
+
+ values[attno++] = CStringGetTextDatum(ConflictTypeNames[conflict_type]);
+
+ if (TransactionIdIsValid(remote_xid))
+ values[attno++] = TransactionIdGetDatum(remote_xid);
+ else
+ nulls[attno++] = true;
+
+ values[attno++] = LSNGetDatum(remote_final_lsn);
+
+ if (remote_commit_ts > 0)
+ values[attno++] = TimestampTzGetDatum(remote_commit_ts);
+ else
+ nulls[attno++] = true;
+
+ if (replorigin_session_origin != InvalidRepOriginId)
+ replorigin_by_oid(replorigin_session_origin, true, &remote_origin);
+
+ if (remote_origin != NULL)
+ values[attno++] = CStringGetTextDatum(remote_origin);
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(searchslot))
+ {
+ Oid replica_index = GetRelationIdentityOrPK(rel);
+
+ /*
+ * If the table has a valid replica identity index, build the index
+ * JSON datum from key value. Otherwise, construct it from the complete
+ * tuple in REPLICA IDENTITY FULL cases.
+ */
+ if (OidIsValid(replica_index))
+ values[attno++] = tuple_table_slot_to_indextup_json(estate, rel,
+ replica_index,
+ searchslot);
+ else
+ values[attno++] = tuple_table_slot_to_json_datum(searchslot);
+ }
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(remoteslot))
+ values[attno++] = tuple_table_slot_to_json_datum(remoteslot);
+ else
+ nulls[attno++] = true;
+
+ values[attno] = build_local_conflicts_json_array(estate, rel,
+ conflict_type,
+ conflicttuples);
+
+ Assert(attno + 1 == MAX_CONFLICT_ATTR_NUM);
+
+ oldctx = MemoryContextSwitchTo(ApplyContext);
+ MyLogicalRepWorker->conflict_log_tuple =
+ heap_form_tuple(RelationGetDescr(conflictlogrel), values, nulls);
+ MemoryContextSwitchTo(oldctx);
}
diff --git a/src/backend/replication/logical/launcher.c b/src/backend/replication/logical/launcher.c
index 3ed86480be2..2dda5a44218 100644
--- a/src/backend/replication/logical/launcher.c
+++ b/src/backend/replication/logical/launcher.c
@@ -477,6 +477,7 @@ retry:
worker->oldest_nonremovable_xid = retain_dead_tuples
? MyReplicationSlot->data.xmin
: InvalidTransactionId;
+ worker->conflict_log_tuple = NULL;
worker->last_lsn = InvalidXLogRecPtr;
TIMESTAMP_NOBEGIN(worker->last_send_time);
TIMESTAMP_NOBEGIN(worker->last_recv_time);
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index ad281e7069b..d4be1122603 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -482,7 +482,9 @@ static bool MySubscriptionValid = false;
static List *on_commit_wakeup_workers_subids = NIL;
bool in_remote_transaction = false;
-static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+TransactionId remote_xid = InvalidTransactionId;
+TimestampTz remote_commit_ts = 0;
/* fields valid only when processing streamed transaction */
static bool in_streamed_transaction = false;
@@ -1219,6 +1221,8 @@ apply_handle_begin(StringInfo s)
set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
remote_final_lsn = begin_data.final_lsn;
+ remote_commit_ts = begin_data.committime;
+ remote_xid = begin_data.xid;
maybe_start_skipping_changes(begin_data.final_lsn);
@@ -1745,6 +1749,10 @@ apply_handle_stream_start(StringInfo s)
/* extract XID of the top-level transaction */
stream_xid = logicalrep_read_stream_start(s, &first_segment);
+ remote_xid = stream_xid;
+ remote_final_lsn = InvalidXLogRecPtr;
+ remote_commit_ts = 0;
+
if (!TransactionIdIsValid(stream_xid))
ereport(ERROR,
(errcode(ERRCODE_PROTOCOL_VIOLATION),
@@ -5609,6 +5617,27 @@ start_apply(XLogRecPtr origin_startpos)
pgstat_report_subscription_error(MySubscription->oid,
MyLogicalRepWorker->type);
+ /*
+ * Insert any pending conflict log tuple under a new transaction.
+ */
+ if (MyLogicalRepWorker->conflict_log_tuple != NULL)
+ {
+ Relation conflictlogrel;
+ ConflictLogDest dest;
+
+ StartTransactionCommand();
+ PushActiveSnapshot(GetTransactionSnapshot());
+
+ /* Open conflict log table and insert the tuple. */
+ conflictlogrel = GetConflictLogDestAndTable(&dest);
+ Assert((dest & CONFLICT_LOG_DEST_TABLE) != 0);
+ InsertConflictLogTuple(conflictlogrel);
+ table_close(conflictlogrel, RowExclusiveLock);
+
+ PopActiveSnapshot();
+ CommitTransactionCommand();
+ }
+
PG_RE_THROW();
}
}
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index 4e4f59bb453..aff1db222a4 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -144,4 +144,7 @@ extern void ReportApplyConflict(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *remoteslot,
List *conflicttuples);
extern void InitConflictIndexes(ResultRelInfo *relInfo);
+extern Relation GetConflictLogDestAndTable(ConflictLogDest *log_dest);
+extern void InsertConflictLogTuple(Relation conflictlogrel);
+extern bool ValidateConflictLogTable(Relation rel);
#endif
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index c1285fdd1bc..5bedfc5450f 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -101,6 +101,9 @@ typedef struct LogicalRepWorker
*/
TransactionId oldest_nonremovable_xid;
+ /* A conflict log tuple that is prepared but not yet inserted. */
+ HeapTuple conflict_log_tuple;
+
/* Stats. */
XLogRecPtr last_lsn;
TimestampTz last_send_time;
@@ -256,6 +259,10 @@ extern PGDLLIMPORT bool InitializingApplyWorker;
extern PGDLLIMPORT List *table_states_not_ready;
+extern XLogRecPtr remote_final_lsn;
+extern TimestampTz remote_commit_ts;
+extern TransactionId remote_xid;
+
extern void logicalrep_worker_attach(int slot);
extern LogicalRepWorker *logicalrep_worker_find(LogicalRepWorkerType wtype,
Oid subid, Oid relid,
diff --git a/src/test/subscription/t/035_conflicts.pl b/src/test/subscription/t/035_conflicts.pl
index ddc75e23fb0..a0f3befc06f 100644
--- a/src/test/subscription/t/035_conflicts.pl
+++ b/src/test/subscription/t/035_conflicts.pl
@@ -50,7 +50,7 @@ $node_subscriber->safe_psql(
'postgres',
"CREATE SUBSCRIPTION sub_tab
CONNECTION '$publisher_connstr application_name=$appname'
- PUBLICATION pub_tab;");
+ PUBLICATION pub_tab WITH (conflict_log_destination=all)");
# Wait for initial table sync to finish
$node_subscriber->wait_for_subscription_sync($node_publisher, $appname);
@@ -86,10 +86,35 @@ $node_subscriber->wait_for_log(
.*Key \(c\)=\(4\); existing local row \(4, 4, 4\); remote row \(2, 3, 4\)./,
$log_offset);
+# Verify the contents of the Conflict Log Table (CLT)
+# This section ensures that the clt contains the expected
+# type and specific key data.
+my $subid = $node_subscriber->safe_psql('postgres',
+ "SELECT oid FROM pg_subscription WHERE subname = 'sub_tab';");
+my $clt = "pg_conflict.pg_conflict_$subid";
+
+# Wait for the conflict to be logged in the CLT
+my $log_check = $node_subscriber->poll_query_until(
+ 'postgres',
+ "SELECT count(*) > 0 FROM $clt;"
+);
+
+my $conflict_check = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM $clt WHERE conflict_type = 'multiple_unique_conflicts';");
+is($conflict_check, 1, 'Verified multiple_unique_conflicts logged into conflict log table');
+
+my $json_query = "SELECT local_conflicts FROM $clt;";
+my $raw_json = $node_subscriber->safe_psql('postgres', $json_query);
+
+# Verify that '2' is present inside the JSON structure using a regex
+# This matches the key/value pattern for "a": 2
+like($raw_json, qr/\\"a\\":2/, 'Verified that key 2 exists in the local_conflicts');
+
pass('multiple_unique_conflicts detected during insert');
# Truncate table to get rid of the error
$node_subscriber->safe_psql('postgres', "TRUNCATE conf_tab;");
+$node_subscriber->safe_psql('postgres', "DELETE FROM $clt");
##################################################
# Test multiple_unique_conflicts due to UPDATE
@@ -118,6 +143,26 @@ $node_subscriber->wait_for_log(
.*Key \(c\)=\(8\); existing local row \(8, 8, 8\); remote row \(6, 7, 8\)./,
$log_offset);
+# Verify the contents of the Conflict Log Table (CLT)
+# This section ensures that the CLT contains the expected
+# type and specific key data.
+
+# Wait for the conflict to be logged in the CLT
+$log_check = $node_subscriber->poll_query_until(
+ 'postgres',
+ "SELECT count(*) > 0 FROM $clt;"
+);
+
+$conflict_check = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM $clt WHERE conflict_type = 'multiple_unique_conflicts';");
+is($conflict_check, 1, 'Verified multiple_unique_conflicts logged into conflict log table');
+
+$raw_json = $node_subscriber->safe_psql('postgres', $json_query);
+
+# Verify that '6' is present inside the JSON structure using a regex
+# This matches the key/value pattern for "a": 6
+like($raw_json, qr/\\"a\\":6/, 'Verified that key 6 exists in the local_conflicts');
+
pass('multiple_unique_conflicts detected during update');
# Truncate table to get rid of the error
--
2.49.0
[application/octet-stream] v23-0003-Doccumentation-patch.patch (11.0K, ../../CAFiTN-v9i9RmDvdUmtMUow4=b+nr0k7LKMyEQ+6ZF=EVdfBhBA@mail.gmail.com/4-v23-0003-Doccumentation-patch.patch)
download | inline diff:
From bdd78671e510b3721023a0027bb69ca3ad6e87a4 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Sun, 21 Dec 2025 18:51:57 +0530
Subject: [PATCH v23 3/3] Doccumentation patch
---
doc/src/sgml/logical-replication.sgml | 116 +++++++++++++++++++++-
doc/src/sgml/ref/alter_subscription.sgml | 14 ++-
doc/src/sgml/ref/create_subscription.sgml | 44 ++++++++
3 files changed, 171 insertions(+), 3 deletions(-)
diff --git a/doc/src/sgml/logical-replication.sgml b/doc/src/sgml/logical-replication.sgml
index 68d6efe5114..ba9342936ee 100644
--- a/doc/src/sgml/logical-replication.sgml
+++ b/doc/src/sgml/logical-replication.sgml
@@ -289,6 +289,19 @@
option of <command>CREATE SUBSCRIPTION</command> for details.
</para>
+ <para>
+ Conflicts that occur during replication are, by default, logged as plain text
+ in the server log, which can make automated monitoring and analysis difficult.
+ The <command>CREATE SUBSCRIPTION</command> command provides the
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination">
+ <literal>conflict_log_destination</literal></link> option to record detailed
+ conflict information in a structured, queryable format. When this parameter
+ is set to <literal>table</literal> or <literal>all</literal>, the system
+ automatically manages a dedicated conflict log table, which is created and
+ dropped along with the subscription. This significantly improves post-mortem
+ analysis and operational visibility of the replication setup.
+ </para>
+
<sect2 id="logical-replication-subscription-slot">
<title>Logical Replication Slot Management</title>
@@ -2118,7 +2131,98 @@ Publications:
</para>
<para>
- The log format for logical replication conflicts is as follows:
+ When the <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>
+ parameter is set to <literal>table</literal> or <literal>all</literal>, the system
+ automatically creates a new table with a predefined schema to log conflict
+ details. This table is created in the dedicated
+ <literal>pg_conflict</literal> namespace. The name of the conflict log table
+ is <literal>pg_conflict_<subid></literal>. The schema of this
+ table is detailed in
+ <xref linkend="logical-replication-conflict-log-schema"/>.
+ </para>
+
+ <table id="logical-replication-conflict-log-schema">
+ <title>Conflict Log Table Schema</title>
+ <tgroup cols="3">
+ <thead>
+ <row>
+ <entry>Column</entry>
+ <entry>Type</entry>
+ <entry>Description</entry>
+ </row>
+ </thead>
+ <tbody>
+ <row>
+ <entry><literal>relid</literal></entry>
+ <entry><type>oid</type></entry>
+ <entry>The OID of the local table where the conflict occurred.</entry>
+ </row>
+ <row>
+ <entry><literal>schemaname</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The schema name of the conflicting table.</entry>
+ </row>
+ <row>
+ <entry><literal>relname</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The name of the conflicting table.</entry>
+ </row>
+ <row>
+ <entry><literal>conflict_type</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The type of conflict that occurred (e.g., <literal>insert_exists</literal>).</entry>
+ </row>
+ <row>
+ <entry><literal>remote_xid</literal></entry>
+ <entry><type>xid</type></entry>
+ <entry>The remote transaction ID that caused the conflict.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_commit_lsn</literal></entry>
+ <entry><type>pg_lsn</type></entry>
+ <entry>The final LSN of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_commit_ts</literal></entry>
+ <entry><type>timestamptz</type></entry>
+ <entry>The remote commit timestamp of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_origin</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The origin of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_tuple</literal></entry>
+ <entry><type>json</type></entry>
+ <entry>The JSON representation of the incoming remote row that caused the conflict.</entry>
+ </row>
+ <row>
+ <entry><literal>local_conflicts</literal></entry>
+ <entry><type>json[]</type></entry>
+ <entry>
+ An array of JSON objects representing the local state for each conflict attempt.
+ Each object includes the local transaction ID (<literal>xid</literal>), commit
+ timestamp (<literal>commit_ts</literal>), origin (<literal>origin</literal>),
+ conflicting key data (<literal>key</literal>), and the full local row
+ image (<literal>tuple</literal>).
+ </entry>
+ </row>
+ </tbody>
+ </tgroup>
+ </table>
+
+ <para>
+ The conflicting row data, including the incoming remote row and the associated
+ local conflict details, is stored in <type>JSON</type> formats (<literal>remote_tuple</literal>
+ and <literal>local_conflicts</literal>) for flexible querying and analysis.
+ </para>
+
+ <para>
+ If <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>
+ is left at the default setting or explicitly configured as <literal>log</literal>
+ or <literal>all</literal>, logical replication conflicts are logged in the
+ following format:
<synopsis>
LOG: conflict detected on relation "<replaceable>schemaname</replaceable>.<replaceable>tablename</replaceable>": conflict=<replaceable>conflict_type</replaceable>
DETAIL: <replaceable class="parameter">detailed_explanation</replaceable>.
@@ -2412,6 +2516,16 @@ CONTEXT: processing remote data for replication origin "pg_16395" during "INSER
key or replica identity defined for it.
</para>
</listitem>
+
+ <listitem>
+ <para>
+ The internal table automatically created when
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>
+ is set to <literal>table</literal> or <literal>all</literal> is excluded from
+ logical replication. It will not be published, even if a publication on the
+ subscriber is defined using <literal>FOR ALL TABLES</literal>.
+ </para>
+ </listitem>
</itemizedlist>
</sect1>
diff --git a/doc/src/sgml/ref/alter_subscription.sgml b/doc/src/sgml/ref/alter_subscription.sgml
index 27c06439f4f..2de2c3c52fb 100644
--- a/doc/src/sgml/ref/alter_subscription.sgml
+++ b/doc/src/sgml/ref/alter_subscription.sgml
@@ -280,8 +280,9 @@ ALTER SUBSCRIPTION <replaceable class="parameter">name</replaceable> RENAME TO <
<link linkend="sql-createsubscription-params-with-origin"><literal>origin</literal></link>,
<link linkend="sql-createsubscription-params-with-failover"><literal>failover</literal></link>,
<link linkend="sql-createsubscription-params-with-two-phase"><literal>two_phase</literal></link>,
- <link linkend="sql-createsubscription-params-with-retain-dead-tuples"><literal>retain_dead_tuples</literal></link>, and
- <link linkend="sql-createsubscription-params-with-max-retention-duration"><literal>max_retention_duration</literal></link>.
+ <link linkend="sql-createsubscription-params-with-retain-dead-tuples"><literal>retain_dead_tuples</literal></link>,
+ <link linkend="sql-createsubscription-params-with-max-retention-duration"><literal>max_retention_duration</literal></link> and,
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>.
Only a superuser can set <literal>password_required = false</literal>.
</para>
@@ -339,6 +340,15 @@ ALTER SUBSCRIPTION <replaceable class="parameter">name</replaceable> RENAME TO <
<quote><literal>pg_conflict_detection</literal></quote>, created to retain
dead tuples for conflict detection, will be dropped.
</para>
+
+ <para>
+ When the <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>
+ parameter is set to <literal>table</literal> or <literal>all</literal>, the system
+ automatically creates the internal conflict log table if it does not already
+ exist. Conversely, if the destination is changed to
+ <literal>log</literal>, logging to the table stops and the internal
+ table is automatically dropped.
+ </para>
</listitem>
</varlistentry>
diff --git a/doc/src/sgml/ref/create_subscription.sgml b/doc/src/sgml/ref/create_subscription.sgml
index b7dd361294b..561d8f11995 100644
--- a/doc/src/sgml/ref/create_subscription.sgml
+++ b/doc/src/sgml/ref/create_subscription.sgml
@@ -247,6 +247,50 @@ CREATE SUBSCRIPTION <replaceable class="parameter">subscription_name</replaceabl
</listitem>
</varlistentry>
+ <varlistentry id="sql-createsubscription-params-with-conflict-log-destination">
+ <term><literal>conflict_log_destination</literal> (<type>enum</type>)</term>
+ <listitem>
+ <para>
+ Specifies the destination for recording logical replication conflicts.
+ </para>
+ <para>
+ The available destinations are:
+ <itemizedlist>
+ <listitem>
+ <para>
+ <literal>log</literal>: Conflict details are recorded in the server log.
+ This is the default behavior.
+ </para>
+ </listitem>
+ <listitem>
+ <para>
+ <literal>table</literal>: The system automatically creates a structured table
+ named <literal>pg_conflict_<subid></literal> in the
+ <literal>pg_conflict</literal> schema. This allows for easy querying and
+ analysis of conflicts.
+ </para>
+ <caution>
+ <para>
+ The internal conflict log table is strictly tied to the lifecycle of the
+ subscription or the <literal>conflict_log_destination</literal> setting. If
+ the subscription is dropped, or if the destination is changed to
+ <literal>log</literal>, the table and all its recorded conflict data are
+ <emphasis>permanently deleted</emphasis>. To perform a post-mortem analysis
+ after removing a subscription, users must manually back up the conflict log
+ table before the deletion occurs.
+ </para>
+ </caution>
+ </listitem>
+ <listitem>
+ <para>
+ <literal>all</literal>: Records to both the server log and the table.
+ </para>
+ </listitem>
+ </itemizedlist>
+ </para>
+ </listitem>
+ </varlistentry>
+
<varlistentry id="sql-createsubscription-params-with-copy-data">
<term><literal>copy_data</literal> (<type>boolean</type>)</term>
<listitem>
--
2.49.0
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-19 06:08 Dilip Kumar <dilipbalaut@gmail.com>
parent: Peter Smith <smithpb2250@gmail.com>
0 siblings, 0 replies; 912+ messages in thread
From: Dilip Kumar @ 2026-01-19 06:08 UTC (permalink / raw)
To: Peter Smith <smithpb2250@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; shveta malik <shveta.malik@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Mon, Jan 12, 2026 at 10:59 AM Peter Smith <smithpb2250@gmail.com> wrote:
>
> Some review comments for patch v20-0003 (docs)
>
> ======
> doc/src/sgml/logical-replication.sgml
>
> (29.8. Conflicts)
>
> 1.
> + When the <link
> linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>
> + is set to <literal>table</literal> or <literal>all</literal>, the system
> + automatically creates a new table with a predefined schema to log conflict
> + details. This table is created in the dedicated
> + <literal>pg_conflict</literal> namespace. The name of the
> conflict log table
> + is pg_conflict_<replaceable>subscription_oid</replaceable>. The
> schema of this
> + table is detailed in
> + <xref linkend="logical-replication-conflict-log-schema"/>.
> + </para>
>
> 1a.
> Instead of saying "When the conflict_log_destination is ...", maybe it
> should say "When the conflict_log_destination parameter is ...".
>
> That should be more consistent with the wording used elsewhere in this patch.
Done in v23-0003
> ~
>
> 1b.
> But, on the CREATE SUBSCRIPTION page, the table name is called:
> <literal>pg_conflict_<subid></literal>
>
> Both places should refer to the name using the same format -- the
> CREATE SUBSCRIPTION way looked good to me.
Done as suggested
> ~~~
>
> 2.
> + The conflicting row data, including the original local tuple and
> + the remote tuple, is stored in <type>JSON</type> columns
> (<literal>local_tuple</literal>
> + and <literal>remote_tuple</literal>) for flexible querying and analysis.
> + </para>
>
> As reported previously [1, comment #5], I didn't see any column called
> 'local_tuple' -- did you mean to refer to the elements of the
> 'local_conflicts' array here?
Fixed and adjusted comments accordingly
> ~~~
>
> 3.
> A previous review comment [1, comment #6 ] suggesting making some
> Chapter 29 supsection/s about "Logging conflicts" was not addressed.
> You disagreed?
I am not sure if we need separate sections for this, so does anyone
else have an opinion on this?
> ======
> doc/src/sgml/ref/create_subscription.sgml
>
> 4.
> A previous review comment [1, comment #13 ] suggesting adding some
> links to refer to Chapter 29 for the logging details was not
> addressed. You disagreed?
I have changed this but it is not included in v23, so will be sent
along with the next version.
--
Regards,
Dilip Kumar
Google
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-19 07:02 Peter Smith <smithpb2250@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: Peter Smith @ 2026-01-19 07:02 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; vignesh C <vignesh21@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Hi Dilip.
Thanks for the updates.
I checked the v23-0001 and v23-0002, and they LGTM.
I'll revisit at patch 0003 (docs) another time.
======
Kind Regards,
Peter Smith.
Fujitsu Australia
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-20 06:29 Peter Smith <smithpb2250@gmail.com>
parent: Peter Smith <smithpb2250@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Peter Smith @ 2026-01-20 06:29 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; vignesh C <vignesh21@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Some review comments for v23-0003 (docs).
======
doc/src/sgml/logical-replication.sgml
1. GENERAL.
Mentioning those specific enum values of coflict_log_dest every time
seemed overkill. In most cases you do really need to mention 'all' or
'log' ot 'table'. IMO the user should use the link to the
'conflict_log_destination' parameter to see that level of detail.
Some examples what I am referring to:
(29.2 Subscription)
BEFORE
The CREATE SUBSCRIPTION command provides the conflict_log_destination
option to record detailed conflict information in a structured,
queryable format. When this parameter is set to table or all, the
system automatically manages a dedicated conflict log table, which is
created and dropped along with the subscription.
SUGGESTION
The CREATE SUBSCRIPTION conflict_log_destination parameter can be set
to create a dedicated conflict log table that is automatically
managed with the subscription.
~
(29.8)
BEFORE
When the conflict_log_destination parameter is set to table or all,
the system automatically creates a new table with a predefined schema
to log conflict details.
SUGGESTION
The conflict_log_destination parameter can automatically create a
dedicated conflict log table.
~
(29.8 Conflicts)
BEFORE
If conflict_log_destination is left at the default setting or
explicitly configured as log or all, logical replication conflicts are
logged in the following format:
SUGGESTION
When conflict_log_destination is set to log conflicts to the server
log, the following format is used:
~
(29.9 Restrictions)
BEFORE:
The internal table automatically created when conflict_log_destination
is set to table or all is excluded from logical replication. It will
not be published, even if a publication on the subscriber is defined
using FOR ALL TABLES.
SUGGESTION
Conflict log tables (see conflict_log_destination parameter) are never
published, even when using FOR ALL TABLES in a publication.
~~~
(29.8 conflicts)
2.
<para>
- The log format for logical replication conflicts is as follows:
+ When the <link
linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>
+ parameter is set to <literal>table</literal> or
<literal>all</literal>, the system
+ automatically creates a new table with a predefined schema to log conflict
+ details. This table is created in the dedicated
+ <literal>pg_conflict</literal> namespace. The name of the
conflict log table
+ is <literal>pg_conflict_<subid></literal>. The schema of this
+ table is detailed in
+ <xref linkend="logical-replication-conflict-log-schema"/>.
+ </para>
Maybe instead of mentioned "schema" twice you can remove that part
that says "with a predefined schema", and then also change:
/The schema of this table is detailed in.../The predefined schema of
this table is detailed in.../
~~~
(29.8 Conflicts)
3.
+ <para>
+ The conflicting row data, including the incoming remote row and
the associated
+ local conflict details, is stored in <type>JSON</type> formats
(<literal>remote_tuple</literal>
+ and <literal>local_conflicts</literal>) for flexible querying and analysis.
+ </para>
Would this be better rearranged slightly to name those columns where
they are mentioned?
SUGGESTION
The conflicting row data, including the incoming remote row
(remote_tuple) and the associated local conflict details
(local_conflicts), is stored in JSON format, for flexible querying and
analysis.
======
doc/src/sgml/ref/create_subscription.sgml
(value: table)
4.
+ <caution>
+ <para>
+ The internal conflict log table is strictly tied to the
lifecycle of the
+ subscription or the
<literal>conflict_log_destination</literal> setting. If
+ the subscription is dropped, or if the destination is changed to
+ <literal>log</literal>, the table and all its recorded
conflict data are
+ <emphasis>permanently deleted</emphasis>. To perform a
post-mortem analysis
+ after removing a subscription, users must manually back
up the conflict log
+ table before the deletion occurs.
+ </para>
+ </caution>
4a.
That last sentence seems a bit awkward. You already explained that the
CLT is removed when a subscription is dropped. Here is one simpler
alternative:
SUGGESTION
If post-mortem analysis may be needed, back up the conflict log table
before removing the subscription.
~
4b.
I also thought this is more readable when that last caution sentence
was in a new <para>, but maybe just my opinion.
~~~
(value: all)
5.
+ <listitem>
+ <para>
+ <literal>all</literal>: Records to both the server log
and the table.
+ </para>
+ </listitem>
I felt that the description for 'all' should refer to those other
destinations explicitly.
SUGGESTION
<literal>all</literal>: Records conflict details to both destinations
<literal>log</literal> and <literal>table</literal>.
======
Kind Regards,
Peter Smith.
Fujitsu Australia
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-20 10:38 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
1 sibling, 0 replies; 912+ messages in thread
From: shveta malik @ 2026-01-20 10:38 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Peter Smith <smithpb2250@gmail.com>; vignesh C <vignesh21@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Tue, Jan 13, 2026 at 4:09 PM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Tue, Jan 13, 2026 at 3:59 PM shveta malik <shveta.malik@gmail.com> wrote:
> >
> > 1)
> > I am able to insert and update rows in pg_conflict.pg_conflict_16394.
> > It should be restricted.
> >
> > 2)
> > I think we need to block 'SELECT for UPDATE' and similar operations on
> > CLT. Currently it is allowed on CLT.
> > See this:
> >
> > postgres=# SELECT * FROM pg_toast.pg_toast_3466 for UPDATE;
> > ERROR: cannot lock rows in TOAST relation "pg_toast_3466"
> > postgres=# SELECT * FROM pg_conflict.pg_conflict_16394 for UPDATE;
> > ....
>
I had a quick look at v23-001, I see that it has INSERT and UPDATE
blocked but SELECt for UPDATE is still working. I do not see any
strong need to provide this flexibility for user-level concurrency
control on conflict-tables. Shall this be blocked as well?
Also, the error I get for INSERT and UPDATE is:
postgres=# update pg_conflict.pg_conflict_16390 set relid=1;
ERROR: cannot modify or insert data for conflict log table "pg_conflict_16390"
DETAIL: Conflict log tables are system-managed and only support
cleanup via DELETE or TRUNCATE.
I feel "cannot modify or insert data into conflict log table ..."
seems more natural.
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-20 13:18 vignesh C <vignesh21@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: vignesh C @ 2026-01-20 13:18 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Peter Smith <smithpb2250@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Mon, 19 Jan 2026 at 10:57, Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
> On Mon, Jan 19, 2026 at 9:42 AM Peter Smith <smithpb2250@gmail.com> wrote:
> >
> > Some review comments for v22-0003.
> >
> > ======
> >
> > 1.
> > It looks like none of my previous v20-0003 review comments [1] have
> > been addressed. Maybe accidentally overlooked?
> >
> > ======
> >
> > 2.
> > + <caution>
> > + <para>
> > + The internal conflict logging table is strictly tied to
> > the lifecycle of the
> > + subscription or the
> > <literal>conflict_log_destination</literal> setting. If
> > + the subscription is dropped, or if the destination is changed to
> > + <literal>log</literal>, the table and all its recorded
> > conflict data are
> > + <emphasis>permanently deleted</emphasis>. To perform a
> > post-mortem analysis
> > + after removing a subscription, users must manually back up
> > or rename the
> > + conflict table before the deletion occurs.
> > + </para>
> > + </caution>
> >
> > 2a.
> > Let's consistently call this the "Conflict log table", same as
> > everywhere else, not "logging table".
> >
> > ~
> >
> > 2b.
> > This is only a caution for the CLT, so I felt it's better to put this
> > in the scope of the 'table' param value.
> >
> > ~~~
> >
> > 3.
> > + analysis of conflicts. This table is automatically
> > dropped when the
> > + subscription is removed.
> >
> > If you move the <caution> to this scope, as suggested above in #2b,
> > then you can remove the sentence "This table is automatically dropped
> > when the subscription is removed", because that is duplicate
> > information you already wrote in the caution.
>
> The attached patch fixes above comments and other comments reported in
> v22-0001 and v22-0002
The tests are failing randomly at the following places
+# Wait for the conflict to be logged in the CLT
+my $log_check = $node_subscriber->poll_query_until(
+ 'postgres',
+ "SELECT count(*) > 0 FROM $clt;"
+);
+
+my $conflict_check = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM $clt WHERE conflict_type =
'multiple_unique_conflicts';");
+is($conflict_check, 1, 'Verified multiple_unique_conflicts logged
into conflict log table');
+# Wait for the conflict to be logged in the CLT
+$log_check = $node_subscriber->poll_query_until(
+ 'postgres',
+ "SELECT count(*) > 0 FROM $clt;"
+);
+
+$conflict_check = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM $clt WHERE conflict_type =
'multiple_unique_conflicts';");
+is($conflict_check, 1, 'Verified multiple_unique_conflicts logged
into conflict log table');
In both places it fails because the number of conflict records
inserted can be more than 1 like below:
[18:35:58.342](1.786s) not ok 1 - Verified multiple_unique_conflicts
logged into conflict log table
[18:35:58.346](0.004s)
[18:35:58.347](0.002s) # Failed test 'Verified
multiple_unique_conflicts logged into conflict log table'
# at t/035_conflicts.pl line 104.
[18:35:58.348](0.000s) # got: '2'
# expected: '1'
How about we check that the record count >= 1 and check for 't'.
Regards,
Vignesh
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-20 21:20 Peter Smith <smithpb2250@gmail.com>
parent: Peter Smith <smithpb2250@gmail.com>
0 siblings, 0 replies; 912+ messages in thread
From: Peter Smith @ 2026-01-20 21:20 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; vignesh C <vignesh21@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, Jan 20, 2026 at 5:29 PM Peter Smith <smithpb2250@gmail.com> wrote:
>
> Some review comments for v23-0003 (docs).
>
> ======
> doc/src/sgml/logical-replication.sgml
>
> 1. GENERAL.
>
> Mentioning those specific enum values of coflict_log_dest every time
> seemed overkill. In most cases you do really need to mention 'all' or
> 'log' ot 'table'. IMO the user should use the link to the
> 'conflict_log_destination' parameter to see that level of detail.
>
Oops. In my post above I wrote " In most cases you do really need to
mention...". That was a typo which was opposite of what I intended --
I meant " In most cases you do NOT really need to mention...".
======
Kind Regards,
Peter Smith.
Fujitsu Australia.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-23 10:54 Dilip Kumar <dilipbalaut@gmail.com>
parent: vignesh C <vignesh21@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Dilip Kumar @ 2026-01-23 10:54 UTC (permalink / raw)
To: vignesh C <vignesh21@gmail.com>; +Cc: Peter Smith <smithpb2250@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, Jan 20, 2026 at 6:48 PM vignesh C <vignesh21@gmail.com> wrote:
>
> On Mon, 19 Jan 2026 at 10:57, Dilip Kumar <dilipbalaut@gmail.com> wrote:
> >
> > On Mon, Jan 19, 2026 at 9:42 AM Peter Smith <smithpb2250@gmail.com> wrote:
> > >
> > > Some review comments for v22-0003.
> > >
> > > ======
> > >
> > > 1.
> > > It looks like none of my previous v20-0003 review comments [1] have
> > > been addressed. Maybe accidentally overlooked?
> > >
> > > ======
> > >
> > > 2.
> > > + <caution>
> > > + <para>
> > > + The internal conflict logging table is strictly tied to
> > > the lifecycle of the
> > > + subscription or the
> > > <literal>conflict_log_destination</literal> setting. If
> > > + the subscription is dropped, or if the destination is changed to
> > > + <literal>log</literal>, the table and all its recorded
> > > conflict data are
> > > + <emphasis>permanently deleted</emphasis>. To perform a
> > > post-mortem analysis
> > > + after removing a subscription, users must manually back up
> > > or rename the
> > > + conflict table before the deletion occurs.
> > > + </para>
> > > + </caution>
> > >
> > > 2a.
> > > Let's consistently call this the "Conflict log table", same as
> > > everywhere else, not "logging table".
> > >
> > > ~
> > >
> > > 2b.
> > > This is only a caution for the CLT, so I felt it's better to put this
> > > in the scope of the 'table' param value.
> > >
> > > ~~~
> > >
> > > 3.
> > > + analysis of conflicts. This table is automatically
> > > dropped when the
> > > + subscription is removed.
> > >
> > > If you move the <caution> to this scope, as suggested above in #2b,
> > > then you can remove the sentence "This table is automatically dropped
> > > when the subscription is removed", because that is duplicate
> > > information you already wrote in the caution.
> >
> > The attached patch fixes above comments and other comments reported in
> > v22-0001 and v22-0002
>
> The tests are failing randomly at the following places
> +# Wait for the conflict to be logged in the CLT
> +my $log_check = $node_subscriber->poll_query_until(
> + 'postgres',
> + "SELECT count(*) > 0 FROM $clt;"
> +);
> +
> +my $conflict_check = $node_subscriber->safe_psql('postgres',
> + "SELECT count(*) FROM $clt WHERE conflict_type =
> 'multiple_unique_conflicts';");
> +is($conflict_check, 1, 'Verified multiple_unique_conflicts logged
> into conflict log table');
>
>
> +# Wait for the conflict to be logged in the CLT
> +$log_check = $node_subscriber->poll_query_until(
> + 'postgres',
> + "SELECT count(*) > 0 FROM $clt;"
> +);
> +
> +$conflict_check = $node_subscriber->safe_psql('postgres',
> + "SELECT count(*) FROM $clt WHERE conflict_type =
> 'multiple_unique_conflicts';");
> +is($conflict_check, 1, 'Verified multiple_unique_conflicts logged
> into conflict log table');
>
>
> In both places it fails because the number of conflict records
> inserted can be more than 1 like below:
> [18:35:58.342](1.786s) not ok 1 - Verified multiple_unique_conflicts
> logged into conflict log table
> [18:35:58.346](0.004s)
> [18:35:58.347](0.002s) # Failed test 'Verified
> multiple_unique_conflicts logged into conflict log table'
> # at t/035_conflicts.pl line 104.
> [18:35:58.348](0.000s) # got: '2'
> # expected: '1'
>
> How about we check that the record count >= 1 and check for 't'.
Yeah that makes sense, fixed that and also fixed Shveta's comments,
now only doc changes suggestions from Peter are pending.
--
Regards,
Dilip Kumar
Google
Attachments:
[application/octet-stream] v24-0003-Doccumentation-patch.patch (11.1K, ../../CAFiTN-t_4XvofM3an-WmykqnPE+9wf9U+o2M7p1CWd9eXkN88Q@mail.gmail.com/2-v24-0003-Doccumentation-patch.patch)
download | inline diff:
From 3d2569e3e8e56f90a73db6093530183b7c81d653 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 21 Jan 2026 18:37:25 +0530
Subject: [PATCH v24 3/3] Doccumentation patch
---
doc/src/sgml/logical-replication.sgml | 116 +++++++++++++++++++++-
doc/src/sgml/ref/alter_subscription.sgml | 14 ++-
doc/src/sgml/ref/create_subscription.sgml | 44 ++++++++
3 files changed, 171 insertions(+), 3 deletions(-)
diff --git a/doc/src/sgml/logical-replication.sgml b/doc/src/sgml/logical-replication.sgml
index 5028fe9af09..5c1f8da63e4 100644
--- a/doc/src/sgml/logical-replication.sgml
+++ b/doc/src/sgml/logical-replication.sgml
@@ -289,6 +289,19 @@
option of <command>CREATE SUBSCRIPTION</command> for details.
</para>
+ <para>
+ Conflicts that occur during replication are, by default, logged as plain text
+ in the server log, which can make automated monitoring and analysis difficult.
+ The <command>CREATE SUBSCRIPTION</command> command provides the
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination">
+ <literal>conflict_log_destination</literal></link> option to record detailed
+ conflict information in a structured, queryable format. When this parameter
+ is set to <literal>table</literal> or <literal>all</literal>, the system
+ automatically manages a dedicated conflict log table, which is created and
+ dropped along with the subscription. This significantly improves post-mortem
+ analysis and operational visibility of the replication setup.
+ </para>
+
<sect2 id="logical-replication-subscription-slot">
<title>Logical Replication Slot Management</title>
@@ -2118,7 +2131,98 @@ Publications:
</para>
<para>
- The log format for logical replication conflicts is as follows:
+ When the <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>
+ parameter is set to <literal>table</literal> or <literal>all</literal>, the system
+ automatically creates a new table with a predefined schema to log conflict
+ details. This table is created in the dedicated
+ <literal>pg_conflict</literal> namespace. The name of the conflict log table
+ is <literal>pg_conflict_<subid></literal>. The schema of this
+ table is detailed in
+ <xref linkend="logical-replication-conflict-log-schema"/>.
+ </para>
+
+ <table id="logical-replication-conflict-log-schema">
+ <title>Conflict Log Table Schema</title>
+ <tgroup cols="3">
+ <thead>
+ <row>
+ <entry>Column</entry>
+ <entry>Type</entry>
+ <entry>Description</entry>
+ </row>
+ </thead>
+ <tbody>
+ <row>
+ <entry><literal>relid</literal></entry>
+ <entry><type>oid</type></entry>
+ <entry>The OID of the local table where the conflict occurred.</entry>
+ </row>
+ <row>
+ <entry><literal>schemaname</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The schema name of the conflicting table.</entry>
+ </row>
+ <row>
+ <entry><literal>relname</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The name of the conflicting table.</entry>
+ </row>
+ <row>
+ <entry><literal>conflict_type</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The type of conflict that occurred (e.g., <literal>insert_exists</literal>).</entry>
+ </row>
+ <row>
+ <entry><literal>remote_xid</literal></entry>
+ <entry><type>xid</type></entry>
+ <entry>The remote transaction ID that caused the conflict.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_commit_lsn</literal></entry>
+ <entry><type>pg_lsn</type></entry>
+ <entry>The final LSN of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_commit_ts</literal></entry>
+ <entry><type>timestamptz</type></entry>
+ <entry>The remote commit timestamp of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_origin</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The origin of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_tuple</literal></entry>
+ <entry><type>json</type></entry>
+ <entry>The JSON representation of the incoming remote row that caused the conflict.</entry>
+ </row>
+ <row>
+ <entry><literal>local_conflicts</literal></entry>
+ <entry><type>json[]</type></entry>
+ <entry>
+ An array of JSON objects representing the local state for each conflict attempt.
+ Each object includes the local transaction ID (<literal>xid</literal>), commit
+ timestamp (<literal>commit_ts</literal>), origin (<literal>origin</literal>),
+ conflicting key data (<literal>key</literal>), and the full local row
+ image (<literal>tuple</literal>).
+ </entry>
+ </row>
+ </tbody>
+ </tgroup>
+ </table>
+
+ <para>
+ The conflicting row data, including the incoming remote row and the associated
+ local conflict details, is stored in <type>JSON</type> formats (<literal>remote_tuple</literal>
+ and <literal>local_conflicts</literal>) for flexible querying and analysis.
+ </para>
+
+ <para>
+ If <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>
+ is left at the default setting or explicitly configured as <literal>log</literal>
+ or <literal>all</literal>, logical replication conflicts are logged in the
+ following format:
<synopsis>
LOG: conflict detected on relation "<replaceable>schemaname</replaceable>.<replaceable>tablename</replaceable>": conflict=<replaceable>conflict_type</replaceable>
DETAIL: <replaceable class="parameter">detailed_explanation</replaceable>[: <replaceable class="parameter">detail_values</replaceable> [, ... ]].
@@ -2411,6 +2515,16 @@ CONTEXT: processing remote data for replication origin "pg_16395" during "INSER
key or replica identity defined for it.
</para>
</listitem>
+
+ <listitem>
+ <para>
+ The internal table automatically created when
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>
+ is set to <literal>table</literal> or <literal>all</literal> is excluded from
+ logical replication. It will not be published, even if a publication on the
+ subscriber is defined using <literal>FOR ALL TABLES</literal>.
+ </para>
+ </listitem>
</itemizedlist>
</sect1>
diff --git a/doc/src/sgml/ref/alter_subscription.sgml b/doc/src/sgml/ref/alter_subscription.sgml
index 27c06439f4f..2de2c3c52fb 100644
--- a/doc/src/sgml/ref/alter_subscription.sgml
+++ b/doc/src/sgml/ref/alter_subscription.sgml
@@ -280,8 +280,9 @@ ALTER SUBSCRIPTION <replaceable class="parameter">name</replaceable> RENAME TO <
<link linkend="sql-createsubscription-params-with-origin"><literal>origin</literal></link>,
<link linkend="sql-createsubscription-params-with-failover"><literal>failover</literal></link>,
<link linkend="sql-createsubscription-params-with-two-phase"><literal>two_phase</literal></link>,
- <link linkend="sql-createsubscription-params-with-retain-dead-tuples"><literal>retain_dead_tuples</literal></link>, and
- <link linkend="sql-createsubscription-params-with-max-retention-duration"><literal>max_retention_duration</literal></link>.
+ <link linkend="sql-createsubscription-params-with-retain-dead-tuples"><literal>retain_dead_tuples</literal></link>,
+ <link linkend="sql-createsubscription-params-with-max-retention-duration"><literal>max_retention_duration</literal></link> and,
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>.
Only a superuser can set <literal>password_required = false</literal>.
</para>
@@ -339,6 +340,15 @@ ALTER SUBSCRIPTION <replaceable class="parameter">name</replaceable> RENAME TO <
<quote><literal>pg_conflict_detection</literal></quote>, created to retain
dead tuples for conflict detection, will be dropped.
</para>
+
+ <para>
+ When the <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>
+ parameter is set to <literal>table</literal> or <literal>all</literal>, the system
+ automatically creates the internal conflict log table if it does not already
+ exist. Conversely, if the destination is changed to
+ <literal>log</literal>, logging to the table stops and the internal
+ table is automatically dropped.
+ </para>
</listitem>
</varlistentry>
diff --git a/doc/src/sgml/ref/create_subscription.sgml b/doc/src/sgml/ref/create_subscription.sgml
index b7dd361294b..561d8f11995 100644
--- a/doc/src/sgml/ref/create_subscription.sgml
+++ b/doc/src/sgml/ref/create_subscription.sgml
@@ -247,6 +247,50 @@ CREATE SUBSCRIPTION <replaceable class="parameter">subscription_name</replaceabl
</listitem>
</varlistentry>
+ <varlistentry id="sql-createsubscription-params-with-conflict-log-destination">
+ <term><literal>conflict_log_destination</literal> (<type>enum</type>)</term>
+ <listitem>
+ <para>
+ Specifies the destination for recording logical replication conflicts.
+ </para>
+ <para>
+ The available destinations are:
+ <itemizedlist>
+ <listitem>
+ <para>
+ <literal>log</literal>: Conflict details are recorded in the server log.
+ This is the default behavior.
+ </para>
+ </listitem>
+ <listitem>
+ <para>
+ <literal>table</literal>: The system automatically creates a structured table
+ named <literal>pg_conflict_<subid></literal> in the
+ <literal>pg_conflict</literal> schema. This allows for easy querying and
+ analysis of conflicts.
+ </para>
+ <caution>
+ <para>
+ The internal conflict log table is strictly tied to the lifecycle of the
+ subscription or the <literal>conflict_log_destination</literal> setting. If
+ the subscription is dropped, or if the destination is changed to
+ <literal>log</literal>, the table and all its recorded conflict data are
+ <emphasis>permanently deleted</emphasis>. To perform a post-mortem analysis
+ after removing a subscription, users must manually back up the conflict log
+ table before the deletion occurs.
+ </para>
+ </caution>
+ </listitem>
+ <listitem>
+ <para>
+ <literal>all</literal>: Records to both the server log and the table.
+ </para>
+ </listitem>
+ </itemizedlist>
+ </para>
+ </listitem>
+ </varlistentry>
+
<varlistentry id="sql-createsubscription-params-with-copy-data">
<term><literal>copy_data</literal> (<type>boolean</type>)</term>
<listitem>
--
2.49.0
[application/octet-stream] v24-0001-Add-configurable-conflict-log-table-for-Logical-.patch (112.1K, ../../CAFiTN-t_4XvofM3an-WmykqnPE+9wf9U+o2M7p1CWd9eXkN88Q@mail.gmail.com/3-v24-0001-Add-configurable-conflict-log-table-for-Logical-.patch)
download | inline diff:
From bf1e94d41bd5b921967962fd4204cc4dad244182 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 17 Dec 2025 11:53:47 +0530
Subject: [PATCH v24 1/3] Add configurable conflict log table for Logical
Replication
This patch adds a feature to provide a structured, queryable record of all
logical replication conflicts. The current approach of logging conflicts as
plain text in the server logs makes it difficult to query, analyze, and
use for external monitoring and automation.
This patch addresses these limitations by introducing a configurable
conflict_log_destination=('log'/'table'/'all') option in the CREATE SUBSCRIPTION
command.
If the user chooses to enable logging to a table (by selecting 'table' or 'all'),
an internal logging table named conflict_log_table_<subid> is automatically
created within a dedicated, system-managed namespace named pg_conflict. This table
is linked to the subscription via an internal dependency, ensuring it is
automatically dropped when the subscription is removed.
The conflict details, including the original and remote tuples, are stored in JSON
columns, providing a flexible format to accommodate different table schemas.
The log table captures essential attributes such as local and remote transaction IDs,
LSNs, commit timestamps, and conflict type, providing a complete record for post-mortem
analysis.
This feature will make logical replication conflicts easier to monitor and manage,
significantly improving the overall resilience and operability of replication setups.
The conflict log tables will not be included in a publication, even if the publication
is configured to include ALL TABLES or ALL TABLES IN SCHEMA.
---
src/backend/catalog/catalog.c | 27 +-
src/backend/catalog/heap.c | 3 +-
src/backend/catalog/namespace.c | 8 +-
src/backend/catalog/pg_publication.c | 14 +-
src/backend/catalog/pg_subscription.c | 7 +
src/backend/commands/subscriptioncmds.c | 239 ++++++++++-
src/backend/commands/tablecmds.c | 6 +-
src/backend/executor/execMain.c | 26 ++
src/bin/psql/describe.c | 16 +-
src/bin/psql/tab-complete.in.c | 8 +-
src/include/catalog/catalog.h | 2 +
src/include/catalog/pg_namespace.dat | 3 +
src/include/catalog/pg_subscription.h | 11 +
src/include/commands/subscriptioncmds.h | 3 +
src/include/replication/conflict.h | 55 +++
src/test/regress/expected/subscription.out | 444 +++++++++++++++++----
src/test/regress/sql/subscription.sql | 217 ++++++++++
src/tools/pgindent/typedefs.list | 2 +
18 files changed, 989 insertions(+), 102 deletions(-)
diff --git a/src/backend/catalog/catalog.c b/src/backend/catalog/catalog.c
index 7be49032934..d438dc682ec 100644
--- a/src/backend/catalog/catalog.c
+++ b/src/backend/catalog/catalog.c
@@ -86,7 +86,8 @@ bool
IsSystemClass(Oid relid, Form_pg_class reltuple)
{
/* IsCatalogRelationOid is a bit faster, so test that first */
- return (IsCatalogRelationOid(relid) || IsToastClass(reltuple));
+ return (IsCatalogRelationOid(relid) || IsToastClass(reltuple)
+ || IsConflictClass(reltuple));
}
/*
@@ -230,6 +231,18 @@ IsToastClass(Form_pg_class reltuple)
return IsToastNamespace(relnamespace);
}
+/*
+ * IsConflictClass - Check if the given pg_class tuple belongs to the conflict
+ * namespace.
+ */
+bool
+IsConflictClass(Form_pg_class reltuple)
+{
+ Oid relnamespace = reltuple->relnamespace;
+
+ return IsConflictNamespace(relnamespace);
+}
+
/*
* IsCatalogNamespace
* True iff namespace is pg_catalog.
@@ -264,6 +277,18 @@ IsToastNamespace(Oid namespaceId)
isTempToastNamespace(namespaceId);
}
+/*
+ * IsConflictNamespace
+ * True iff namespace is pg_conflict.
+ *
+ * Does not perform any catalog accesses.
+ */
+bool
+IsConflictNamespace(Oid namespaceId)
+{
+ return namespaceId == PG_CONFLICT_NAMESPACE;
+}
+
/*
* IsReservedName
diff --git a/src/backend/catalog/heap.c b/src/backend/catalog/heap.c
index 606434823cf..10dadf378a4 100644
--- a/src/backend/catalog/heap.c
+++ b/src/backend/catalog/heap.c
@@ -314,7 +314,8 @@ heap_create(const char *relname,
*/
if (!allow_system_table_mods &&
((IsCatalogNamespace(relnamespace) && relkind != RELKIND_INDEX) ||
- IsToastNamespace(relnamespace)) &&
+ IsToastNamespace(relnamespace) ||
+ IsConflictNamespace(relnamespace)) &&
IsNormalProcessingMode())
ereport(ERROR,
(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
diff --git a/src/backend/catalog/namespace.c b/src/backend/catalog/namespace.c
index c3b79a2ba48..400292fd06b 100644
--- a/src/backend/catalog/namespace.c
+++ b/src/backend/catalog/namespace.c
@@ -3523,7 +3523,7 @@ LookupCreationNamespace(const char *nspname)
*
* We complain if either the old or new namespaces is a temporary schema
* (or temporary toast schema), or if either the old or new namespaces is the
- * TOAST schema.
+ * TOAST schema or the CONFLICT schema.
*/
void
CheckSetNamespace(Oid oldNspOid, Oid nspOid)
@@ -3539,6 +3539,12 @@ CheckSetNamespace(Oid oldNspOid, Oid nspOid)
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("cannot move objects into or out of TOAST schema")));
+
+ /* similarly for CONFLICT schema */
+ if (nspOid == PG_CONFLICT_NAMESPACE || oldNspOid == PG_CONFLICT_NAMESPACE)
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot move objects into or out of CONFLICT schema")));
}
/*
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index 9a4791c573e..a33c33efe0d 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -31,6 +31,7 @@
#include "catalog/pg_publication_rel.h"
#include "catalog/pg_type.h"
#include "commands/publicationcmds.h"
+#include "commands/subscriptioncmds.h"
#include "funcapi.h"
#include "utils/array.h"
#include "utils/builtins.h"
@@ -85,6 +86,15 @@ check_publication_add_relation(Relation targetrel)
errmsg("cannot add relation \"%s\" to publication",
RelationGetRelationName(targetrel)),
errdetail("This operation is not supported for unlogged tables.")));
+
+ /* Can't be conflict log table */
+ if (IsConflictNamespace(RelationGetNamespace(targetrel)))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot add relation \"%s.%s\" to publication",
+ get_namespace_name(RelationGetNamespace(targetrel)),
+ RelationGetRelationName(targetrel)),
+ errdetail("This operation is not supported for conflict log tables.")));
}
/*
@@ -95,7 +105,8 @@ static void
check_publication_add_schema(Oid schemaid)
{
/* Can't be system namespace */
- if (IsCatalogNamespace(schemaid) || IsToastNamespace(schemaid))
+ if (IsCatalogNamespace(schemaid) || IsToastNamespace(schemaid) ||
+ IsConflictNamespace(schemaid))
ereport(ERROR,
(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
errmsg("cannot add schema \"%s\" to publication",
@@ -139,6 +150,7 @@ is_publishable_class(Oid relid, Form_pg_class reltuple)
reltuple->relkind == RELKIND_PARTITIONED_TABLE ||
reltuple->relkind == RELKIND_SEQUENCE) &&
!IsCatalogRelationOid(relid) &&
+ !IsConflictClass(reltuple) &&
reltuple->relpersistence == RELPERSISTENCE_PERMANENT &&
relid >= FirstNormalObjectId;
}
diff --git a/src/backend/catalog/pg_subscription.c b/src/backend/catalog/pg_subscription.c
index 2b103245290..285a598497d 100644
--- a/src/backend/catalog/pg_subscription.c
+++ b/src/backend/catalog/pg_subscription.c
@@ -106,6 +106,7 @@ GetSubscription(Oid subid, bool missing_ok)
sub->retaindeadtuples = subform->subretaindeadtuples;
sub->maxretention = subform->submaxretention;
sub->retentionactive = subform->subretentionactive;
+ sub->conflictlogrelid = subform->subconflictlogrelid;
/* Get conninfo */
datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
@@ -141,6 +142,12 @@ GetSubscription(Oid subid, bool missing_ok)
Anum_pg_subscription_suborigin);
sub->origin = TextDatumGetCString(datum);
+ /* Get conflict log destination */
+ datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
+ tup,
+ Anum_pg_subscription_subconflictlogdest);
+ sub->conflictlogdest = TextDatumGetCString(datum);
+
/* Is the subscription owner a superuser? */
sub->ownersuperuser = superuser_arg(sub->owner);
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index d6674f20fc2..de1977f81ef 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -15,25 +15,31 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/htup_details.h"
#include "access/table.h"
#include "access/twophase.h"
#include "access/xact.h"
#include "catalog/catalog.h"
#include "catalog/dependency.h"
+#include "catalog/heap.h"
#include "catalog/indexing.h"
#include "catalog/namespace.h"
#include "catalog/objectaccess.h"
#include "catalog/objectaddress.h"
+#include "catalog/pg_am_d.h"
#include "catalog/pg_authid_d.h"
#include "catalog/pg_database_d.h"
+#include "catalog/pg_namespace.h"
#include "catalog/pg_subscription.h"
#include "catalog/pg_subscription_rel.h"
#include "catalog/pg_type.h"
+#include "commands/comment.h"
#include "commands/defrem.h"
#include "commands/event_trigger.h"
#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "miscadmin.h"
#include "nodes/makefuncs.h"
#include "pgstat.h"
@@ -51,6 +57,7 @@
#include "utils/lsyscache.h"
#include "utils/memutils.h"
#include "utils/pg_lsn.h"
+#include "utils/regproc.h"
#include "utils/syscache.h"
/*
@@ -75,6 +82,7 @@
#define SUBOPT_MAX_RETENTION_DURATION 0x00008000
#define SUBOPT_LSN 0x00010000
#define SUBOPT_ORIGIN 0x00020000
+#define SUBOPT_CONFLICT_LOG_DEST 0x00040000
/* check if the 'val' has 'bits' set */
#define IsSet(val, bits) (((val) & (bits)) == (bits))
@@ -103,6 +111,7 @@ typedef struct SubOpts
bool retaindeadtuples;
int32 maxretention;
char *origin;
+ ConflictLogDest conflictlogdest;
XLogRecPtr lsn;
} SubOpts;
@@ -135,7 +144,7 @@ static List *merge_publications(List *oldpublist, List *newpublist, bool addpub,
static void ReportSlotConnectionError(List *rstates, Oid subid, char *slotname, char *err);
static void CheckAlterSubOption(Subscription *sub, const char *option,
bool slot_needs_update, bool isTopLevel);
-
+static Oid create_conflict_log_table(Oid subid, char *subname);
/*
* Common option parsing function for CREATE and ALTER SUBSCRIPTION commands.
@@ -191,6 +200,8 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->maxretention = 0;
if (IsSet(supported_opts, SUBOPT_ORIGIN))
opts->origin = pstrdup(LOGICALREP_ORIGIN_ANY);
+ if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DEST))
+ opts->conflictlogdest = CONFLICT_LOG_DEST_LOG;
/* Parse options */
foreach(lc, stmt_options)
@@ -402,6 +413,18 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->specified_opts |= SUBOPT_LSN;
opts->lsn = lsn;
}
+ else if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DEST) &&
+ strcmp(defel->defname, "conflict_log_destination") == 0)
+ {
+ char *val;
+
+ if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_LOG_DEST))
+ errorConflictingDefElem(defel, pstate);
+
+ val = defGetString(defel);
+ opts->conflictlogdest = GetLogDestination(val);
+ opts->specified_opts |= SUBOPT_CONFLICT_LOG_DEST;
+ }
else
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
@@ -599,6 +622,7 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bits32 supported_opts;
SubOpts opts = {0};
AclResult aclresult;
+ Oid logrelid = InvalidOid;
/*
* Parse and check options.
@@ -612,7 +636,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
SUBOPT_DISABLE_ON_ERR | SUBOPT_PASSWORD_REQUIRED |
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
- SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN);
+ SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_DEST);
parse_subscription_options(pstate, stmt->options, supported_opts, &opts);
/*
@@ -747,6 +772,18 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
values[Anum_pg_subscription_suborigin - 1] =
CStringGetTextDatum(opts.origin);
+ /* Always set the destination, default will be 'log'. */
+ values[Anum_pg_subscription_subconflictlogdest - 1] =
+ CStringGetTextDatum(ConflictLogDestNames[opts.conflictlogdest]);
+
+ /* If logging to a table is required, physically create the table. */
+ if (IsSet(opts.conflictlogdest, CONFLICT_LOG_DEST_TABLE))
+ logrelid = create_conflict_log_table(subid, stmt->subname);
+
+ /* Store table OID in the catalog. */
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(logrelid);
+
tup = heap_form_tuple(RelationGetDescr(rel), values, nulls);
/* Insert tuple into catalog. */
@@ -1410,7 +1447,8 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
SUBOPT_MAX_RETENTION_DURATION |
- SUBOPT_ORIGIN);
+ SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_DEST);
parse_subscription_options(pstate, stmt->options,
supported_opts, &opts);
@@ -1665,6 +1703,63 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
origin = opts.origin;
}
+ if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_LOG_DEST))
+ {
+ ConflictLogDest old_dest =
+ GetLogDestination(sub->conflictlogdest);
+
+ if (opts.conflictlogdest != old_dest)
+ {
+ bool want_table = IsSet(opts.conflictlogdest,
+ CONFLICT_LOG_DEST_TABLE);
+ bool has_oldtable =
+ IsSet(old_dest, CONFLICT_LOG_DEST_TABLE);
+
+ values[Anum_pg_subscription_subconflictlogdest - 1] =
+ CStringGetTextDatum(ConflictLogDestNames[opts.conflictlogdest]);
+ replaces[Anum_pg_subscription_subconflictlogdest - 1] = true;
+
+ if (want_table && !has_oldtable)
+ {
+ Oid relid;
+
+ relid = create_conflict_log_table(subid, sub->name);
+
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(relid);
+ replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
+ true;
+ }
+ else if (!want_table && has_oldtable)
+ {
+ ObjectAddress object;
+
+ /*
+ * Conflict log tables are recorded as internal
+ * dependencies of the subscription. Drop the
+ * table if it is not required anymore to avoid
+ * stale or orphaned relations.
+ *
+ * XXX: At present, only conflict log tables are
+ * managed this way. In future if we introduce
+ * additional internal dependencies, we may need
+ * a targeted deletion to avoid deletion of any
+ * other objects.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId,
+ subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(InvalidOid);
+ replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
+ true;
+ }
+ }
+ }
+
update_tuple = true;
break;
}
@@ -2027,6 +2122,7 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
Form_pg_subscription form;
List *rstates;
bool must_use_password;
+ ObjectAddress object;
/*
* The launcher may concurrently start a new worker for this subscription.
@@ -2184,6 +2280,19 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
/* Clean up dependencies */
deleteSharedDependencyRecordsFor(SubscriptionRelationId, subid, 0);
+ /*
+ * Conflict log tables are recorded as internal dependencies of the
+ * subscription. We must drop the dependent objects before the
+ * subscription itself is removed. By using
+ * PERFORM_DELETION_SKIP_ORIGINAL, we ensure that only the conflict log
+ * table is reaped while the subscription remains for the final deletion
+ * step.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId, subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
/* Remove any associated relation synchronization states. */
RemoveSubscriptionRel(subid, InvalidOid);
@@ -3190,3 +3299,127 @@ defGetStreamingMode(DefElem *def)
def->defname)));
return LOGICALREP_STREAM_OFF; /* keep compiler quiet */
}
+
+/*
+ * Builds the TupleDesc for the conflict log table.
+ */
+static TupleDesc
+create_conflict_log_table_tupdesc(void)
+{
+ TupleDesc tupdesc;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_CONFLICT_ATTR_NUM);
+
+ for (int i = 0; i < MAX_CONFLICT_ATTR_NUM; i++)
+ {
+ Oid type_oid = ConflictLogSchema[i].atttypid;
+
+ /*
+ * Special handling for the JSON array type for proper
+ * TupleDescInitEntry call.
+ */
+ if (type_oid == JSONARRAYOID)
+ type_oid = get_array_type(JSONOID);
+
+ TupleDescInitEntry(tupdesc, i + 1,
+ ConflictLogSchema[i].attname,
+ type_oid,
+ -1, 0);
+ }
+
+ return BlessTupleDesc(tupdesc);
+}
+
+/*
+ * Create a structured conflict log table for a subscription.
+ *
+ * The table is created within the system-managed 'pg_conflict' namespace.
+ * The table name is generated automatically using the subscription's OID
+ * (e.g., "pg_conflict_<subid>") to ensure uniqueness within the cluster and
+ * to avoid collisions during subscription renames.
+ */
+static Oid
+create_conflict_log_table(Oid subid, char *subname)
+{
+ TupleDesc tupdesc;
+ Oid relid;
+ ObjectAddress myself;
+ ObjectAddress subaddr;
+ char relname[NAMEDATALEN];
+
+ snprintf(relname, NAMEDATALEN, "pg_conflict_%u", subid);
+
+ /* There can not be an existing table with the same name. */
+ Assert(!OidIsValid(get_relname_relid(relname, PG_CONFLICT_NAMESPACE)));
+
+ /* Build the tuple descriptor for the new table. */
+ tupdesc = create_conflict_log_table_tupdesc();
+
+ /* Create conflict log table. */
+ relid = heap_create_with_catalog(relname,
+ PG_CONFLICT_NAMESPACE,
+ 0, /* tablespace */
+ InvalidOid, /* relid */
+ InvalidOid, /* reltypeid */
+ InvalidOid, /* reloftypeid */
+ GetUserId(),
+ HEAP_TABLE_AM_OID,
+ tupdesc,
+ NIL,
+ RELKIND_RELATION,
+ RELPERSISTENCE_PERMANENT,
+ false, /* shared_relation */
+ false, /* mapped_relation */
+ ONCOMMIT_NOOP,
+ (Datum) 0, /* reloptions */
+ false, /* use_user_acl */
+ true, /* allow_system_table_mods */
+ true, /* is_internal */
+ InvalidOid, /* relrewrite */
+ NULL); /* typaddress */
+
+ /*
+ * Establish an internal dependency between the conflict log table and
+ * the subscription.
+ *
+ * We use DEPENDENCY_INTERNAL to signify that the table's lifecycle is
+ * strictly tied to the subscription, similar to how a TOAST table relates
+ * to its main table or a sequence relates to an identity column.
+ *
+ * This ensures the conflict log table is automatically reaped during a
+ * DROP SUBSCRIPTION via performDeletion().
+ */
+ ObjectAddressSet(myself, RelationRelationId, relid);
+ ObjectAddressSet(subaddr, SubscriptionRelationId, subid);
+ recordDependencyOn(&myself, &subaddr, DEPENDENCY_INTERNAL);
+
+ /* Release tuple descriptor memory. */
+ FreeTupleDesc(tupdesc);
+
+ return relid;
+}
+
+/*
+ * GetLogDestination
+ *
+ * Convert string to enum by comparing against standardized labels.
+ */
+ConflictLogDest
+GetLogDestination(const char *dest)
+{
+ /* Empty string or NULL defaults to LOG. */
+ if (dest == NULL || dest[0] == '\0' || pg_strcasecmp(dest, "log") == 0)
+ return CONFLICT_LOG_DEST_LOG;
+
+ if (pg_strcasecmp(dest, "table") == 0)
+ return CONFLICT_LOG_DEST_TABLE;
+
+ if (pg_strcasecmp(dest, "all") == 0)
+ return CONFLICT_LOG_DEST_ALL;
+
+ /* Unrecognized string. */
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("unrecognized conflict_log_destination value: \"%s\"", dest),
+ errhint("Valid values are \"log\", \"table\", and \"all\".")));
+}
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index f976c0e5c7e..4bf39f359d7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -2398,9 +2398,11 @@ truncate_check_rel(Oid relid, Form_pg_class reltuple)
* pg_largeobject and pg_largeobject_metadata to be truncated as part of
* pg_upgrade, because we need to change its relfilenode to match the old
* cluster, and allowing a TRUNCATE command to be executed is the easiest
- * way of doing that.
+ * way of doing that. We also allow TRUNCATE on the conflict log tables,
+ * to permit users to manually prune these logs to manage disk space.
*/
- if (!allowSystemTableMods && IsSystemClass(relid, reltuple)
+ if (!allowSystemTableMods && IsSystemClass(relid, reltuple) &&
+ !IsConflictClass(reltuple)
&& (!IsBinaryUpgrade ||
(relid != LargeObjectRelationId &&
relid != LargeObjectMetadataRelationId)))
diff --git a/src/backend/executor/execMain.c b/src/backend/executor/execMain.c
index bfd3ebc601e..e419a2fcfe7 100644
--- a/src/backend/executor/execMain.c
+++ b/src/backend/executor/execMain.c
@@ -1170,6 +1170,21 @@ CheckValidResultRel(ResultRelInfo *resultRelInfo, CmdType operation,
RelationGetRelationName(resultRel))));
break;
}
+
+ /*
+ * Conflict log tables are managed by the system to record logical
+ * replication conflicts. We allow DELETE to permit users to manually
+ * prune or truncate these logs, but manual data insertion or modification
+ * (INSERT, UPDATE, MERGE) is prohibited to maintain the integrity of the
+ * system-generated logs.
+ */
+ if (IsConflictNamespace(RelationGetNamespace(resultRel)) &&
+ operation != CMD_DELETE)
+ ereport(ERROR,
+ (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+ errmsg("cannot modify or insert data into conflict log table \"%s\"",
+ RelationGetRelationName(resultRel)),
+ errdetail("Conflict log tables are system-managed and only support cleanup via DELETE or TRUNCATE.")));
}
/*
@@ -1234,6 +1249,17 @@ CheckValidRowMarkRel(Relation rel, RowMarkType markType)
RelationGetRelationName(rel))));
break;
}
+
+ /*
+ * Conflict log tables are managed by the system to record logical
+ * replication conflicts. We do not allow locking rows in CONFLICT
+ * relations.
+ */
+ if (IsConflictNamespace(RelationGetNamespace(rel)))
+ ereport(ERROR,
+ (errcode(ERRCODE_WRONG_OBJECT_TYPE),
+ errmsg("cannot lock rows in CONFLICT relation \"%s\"",
+ RelationGetRelationName(rel))));
}
/*
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index 3584c4e1428..21cfe390430 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -6806,7 +6806,7 @@ describeSubscriptions(const char *pattern, bool verbose)
printQueryOpt myopt = pset.popt;
static const bool translate_columns[] = {false, false, false, false,
false, false, false, false, false, false, false, false, false, false,
- false, false, false, false};
+ false, false, false, false, false, false};
if (pset.sversion < 100000)
{
@@ -6900,6 +6900,20 @@ describeSubscriptions(const char *pattern, bool verbose)
appendPQExpBuffer(&buf,
", subskiplsn AS \"%s\"\n",
gettext_noop("Skip LSN"));
+
+ /* Conflict log destination is supported in v19 and higher */
+ if (pset.sversion >= 190000)
+ {
+ appendPQExpBuffer(&buf,
+ ", subconflictlogdest AS \"%s\"\n",
+ gettext_noop("Conflict log destination"));
+
+ appendPQExpBuffer(&buf,
+ ", (CASE WHEN subconflictlogdest IN ('table', 'all') "
+ " THEN 'pg_conflict_' || oid "
+ " ELSE '-' END) AS \"%s\"\n",
+ gettext_noop("Conflict log table"));
+ }
}
/* Only display subscriptions in current database. */
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 8b91bc00062..12eee8a0d43 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -2344,8 +2344,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("(", "PUBLICATION");
/* ALTER SUBSCRIPTION <name> SET ( */
else if (Matches("ALTER", "SUBSCRIPTION", MatchAny, MatchAnyN, "SET", "("))
- COMPLETE_WITH("binary", "disable_on_error", "failover",
- "max_retention_duration", "origin",
+ COMPLETE_WITH("binary", "conflict_log_destination", "disable_on_error",
+ "failover", "max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
"synchronous_commit", "two_phase");
@@ -3860,8 +3860,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("WITH (");
/* Complete "CREATE SUBSCRIPTION <name> ... WITH ( <opt>" */
else if (Matches("CREATE", "SUBSCRIPTION", MatchAnyN, "WITH", "("))
- COMPLETE_WITH("binary", "connect", "copy_data", "create_slot",
- "disable_on_error", "enabled", "failover",
+ COMPLETE_WITH("binary", "conflict_log_destination", "connect", "copy_data",
+ "create_slot", "disable_on_error", "enabled", "failover",
"max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
diff --git a/src/include/catalog/catalog.h b/src/include/catalog/catalog.h
index a9d6e8ea986..8193229f2e2 100644
--- a/src/include/catalog/catalog.h
+++ b/src/include/catalog/catalog.h
@@ -25,6 +25,7 @@ extern bool IsInplaceUpdateRelation(Relation relation);
extern bool IsSystemClass(Oid relid, Form_pg_class reltuple);
extern bool IsToastClass(Form_pg_class reltuple);
+extern bool IsConflictClass(Form_pg_class reltuple);
extern bool IsCatalogRelationOid(Oid relid);
extern bool IsCatalogTextUniqueIndexOid(Oid relid);
@@ -32,6 +33,7 @@ extern bool IsInplaceUpdateOid(Oid relid);
extern bool IsCatalogNamespace(Oid namespaceId);
extern bool IsToastNamespace(Oid namespaceId);
+extern bool IsConflictNamespace(Oid namespaceId);
extern bool IsReservedName(const char *name);
diff --git a/src/include/catalog/pg_namespace.dat b/src/include/catalog/pg_namespace.dat
index 3075e142c73..b45cb9383a8 100644
--- a/src/include/catalog/pg_namespace.dat
+++ b/src/include/catalog/pg_namespace.dat
@@ -18,6 +18,9 @@
{ oid => '99', oid_symbol => 'PG_TOAST_NAMESPACE',
descr => 'reserved schema for TOAST tables',
nspname => 'pg_toast', nspacl => '_null_' },
+{ oid => '1382', oid_symbol => 'PG_CONFLICT_NAMESPACE',
+ descr => 'reserved schema for subscription-specific conflict log tables',
+ nspname => 'pg_conflict', nspacl => '_null_' },
# update dumpNamespace() if changing this descr
{ oid => '2200', oid_symbol => 'PG_PUBLIC_NAMESPACE',
descr => 'standard public schema',
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index f3571d2bfcf..76a4638b389 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -90,6 +90,7 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
* exceeded max_retention_duration, when
* defined */
+ Oid subconflictlogrelid; /* Relid of the conflict log table. */
#ifdef CATALOG_VARLEN /* variable-length fields start here */
/* Connection string to the publisher */
text subconninfo BKI_FORCE_NOT_NULL;
@@ -103,6 +104,14 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
/* List of publications subscribed to */
text subpublications[1] BKI_FORCE_NOT_NULL;
+ /*
+ * Strategy for logging replication conflicts:
+ * 'log' - server log only,
+ * 'table' - conflict log table only,
+ * 'all' - both log and table.
+ */
+ text subconflictlogdest;
+
/* Only publish data originating from the specified origin */
text suborigin BKI_DEFAULT(LOGICALREP_ORIGIN_ANY);
#endif
@@ -152,12 +161,14 @@ typedef struct Subscription
* and the retention duration has not
* exceeded max_retention_duration, when
* defined */
+ Oid conflictlogrelid; /* conflict log table Oid */
char *conninfo; /* Connection string to the publisher */
char *slotname; /* Name of the replication slot */
char *synccommit; /* Synchronous commit setting for worker */
List *publications; /* List of publication names to subscribe to */
char *origin; /* Only publish data originating from the
* specified origin */
+ char *conflictlogdest; /* Conflict log destination */
} Subscription;
#ifdef EXPOSE_TO_CLIENT_CODE
diff --git a/src/include/commands/subscriptioncmds.h b/src/include/commands/subscriptioncmds.h
index 63504232a14..a895127f8fe 100644
--- a/src/include/commands/subscriptioncmds.h
+++ b/src/include/commands/subscriptioncmds.h
@@ -17,6 +17,7 @@
#include "catalog/objectaddress.h"
#include "parser/parse_node.h"
+#include "replication/conflict.h"
extern ObjectAddress CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bool isTopLevel);
@@ -36,4 +37,6 @@ extern void CheckSubDeadTupleRetention(bool check_guc, bool sub_disabled,
bool retention_active,
bool max_retention_set);
+extern ConflictLogDest GetLogDestination(const char *dest);
+
#endif /* SUBSCRIPTIONCMDS_H */
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index 2de7b624eb2..31facc1ec7e 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -10,6 +10,7 @@
#define CONFLICT_H
#include "access/xlogdefs.h"
+#include "catalog/pg_type.h"
#include "nodes/pg_list.h"
#include "utils/timestamp.h"
@@ -79,6 +80,60 @@ typedef struct ConflictTupleInfo
* conflicting local row occurred */
} ConflictTupleInfo;
+/*
+ * Conflict log destination types.
+ *
+ * These values are defined as bitmask flags to allow for multiple simultaneous
+ * logging destinations (e.g., logging to both system logs and a table).
+ * Internally, we use these for bitwise comparisons (IsSet), but the string
+ * representation is stored in pg_subscription.subconflictlogdest.
+ */
+typedef enum ConflictLogDest
+{
+ /* Log conflicts to the server logs */
+ CONFLICT_LOG_DEST_LOG = 1 << 0, /* 0x01 */
+
+ /* Log conflicts to an internally managed conflict log table */
+ CONFLICT_LOG_DEST_TABLE = 1 << 1, /* 0x02 */
+
+ /* Convenience bitmask for all supported destinations */
+ CONFLICT_LOG_DEST_ALL = (CONFLICT_LOG_DEST_LOG | CONFLICT_LOG_DEST_TABLE)
+} ConflictLogDest;
+
+/*
+ * Array mapping for converting internal enum to string.
+ */
+static const char *const ConflictLogDestNames[] = {
+ [CONFLICT_LOG_DEST_LOG] = "log",
+ [CONFLICT_LOG_DEST_TABLE] = "table",
+ [CONFLICT_LOG_DEST_ALL] = "all"
+};
+
+/* Structure to hold metadata for one column of the conflict log table */
+typedef struct ConflictLogColumnDef
+{
+ const char *attname; /* Column name */
+ Oid atttypid; /* Data type OID */
+} ConflictLogColumnDef;
+
+/* The single source of truth for the conflict log table schema */
+static const ConflictLogColumnDef ConflictLogSchema[] =
+{
+ { .attname = "relid", .atttypid = OIDOID },
+ { .attname = "schemaname", .atttypid = TEXTOID },
+ { .attname = "relname", .atttypid = TEXTOID },
+ { .attname = "conflict_type", .atttypid = TEXTOID },
+ { .attname = "remote_xid", .atttypid = XIDOID },
+ { .attname = "remote_commit_lsn",.atttypid = LSNOID },
+ { .attname = "remote_commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "remote_origin", .atttypid = TEXTOID },
+ { .attname = "replica_identity", .atttypid = JSONOID },
+ { .attname = "remote_tuple", .atttypid = JSONOID },
+ { .attname = "local_conflicts", .atttypid = JSONARRAYOID }
+};
+
+#define MAX_CONFLICT_ATTR_NUM lengthof(ConflictLogSchema)
+
extern bool GetTupleTransactionInfo(TupleTableSlot *localslot,
TransactionId *xmin,
RepOriginId *localorigin,
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index b3eccd8afe3..b94bcc3cc23 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -116,18 +116,18 @@ CREATE SUBSCRIPTION regress_testsub4 CONNECTION 'dbname=regress_doesnotexist' PU
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub4 SET (origin = any);
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
DROP SUBSCRIPTION regress_testsub3;
@@ -145,10 +145,10 @@ ALTER SUBSCRIPTION regress_testsub CONNECTION 'foobar';
ERROR: invalid connection string syntax: missing "=" after "foobar" in connection info string
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET PUBLICATION testpub2, testpub3 WITH (refresh = false);
@@ -157,10 +157,10 @@ ALTER SUBSCRIPTION regress_testsub SET (slot_name = 'newname');
ALTER SUBSCRIPTION regress_testsub SET (password_required = false);
ALTER SUBSCRIPTION regress_testsub SET (run_as_owner = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (password_required = true);
@@ -176,10 +176,10 @@ ERROR: unrecognized subscription parameter: "create_slot"
-- ok
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/12345');
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345 | log | -
(1 row)
-- ok - with lsn = NONE
@@ -188,10 +188,10 @@ ALTER SUBSCRIPTION regress_testsub SKIP (lsn = NONE);
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/0');
ERROR: invalid WAL location (LSN): 0/0
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log | -
(1 row)
BEGIN;
@@ -223,10 +223,10 @@ ALTER SUBSCRIPTION regress_testsub_foo SET (synchronous_commit = foobar);
ERROR: invalid value for parameter "synchronous_commit": "foobar"
HINT: Available values: local, remote_write, remote_apply, on, off.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
----------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+---------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------+--------------------
+ regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000 | log | -
(1 row)
-- rename back to keep the rest simple
@@ -255,19 +255,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (binary = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -279,27 +279,27 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = parallel);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
-- fail - publication already exists
@@ -314,10 +314,10 @@ ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refr
ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refresh = false);
ERROR: publication "testpub1" is already in subscription "regress_testsub"
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
-- fail - publication used more than once
@@ -332,10 +332,10 @@ ERROR: publication "testpub3" is not in subscription "regress_testsub"
-- ok - delete publications
ALTER SUBSCRIPTION regress_testsub DROP PUBLICATION testpub1, testpub2 WITH (refresh = false);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -371,19 +371,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
-- we can alter streaming when two_phase enabled
ALTER SUBSCRIPTION regress_testsub SET (streaming = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -393,10 +393,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -409,18 +409,18 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (disable_on_error = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -433,10 +433,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -450,19 +450,19 @@ NOTICE: max_retention_duration is ineffective when retain_dead_tuples is disabl
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
-- ok
ALTER SUBSCRIPTION regress_testsub SET (max_retention_duration = 0);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -517,6 +517,274 @@ COMMIT;
-- ok, owning it is enough for this stuff
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG DESTINATION TESTS
+--
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+-- fail - unrecognized parameter value
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
+ERROR: unrecognized conflict_log_destination value: "invalid"
+HINT: Valid values are "log", "table", and "all".
+-- verify subconflictlogdest is 'log' and relid is 0 (InvalidOid) for default case
+CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
+ subname | subconflictlogdest | subconflictlogrelid
+------------------------------+--------------------+---------------------
+ regress_conflict_log_default | log | 0
+(1 row)
+
+-- verify empty string defaults to 'log'
+CREATE SUBSCRIPTION regress_conflict_empty_str CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = '');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
+ subname | subconflictlogdest | subconflictlogrelid
+----------------------------+--------------------+---------------------
+ regress_conflict_empty_str | log | 0
+(1 row)
+
+-- this should generate an internal conflict log table named pg_conflict_$subid$
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- check metadata in pg_subscription: destination should be 'table' and relid valid
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname | subconflictlogdest | has_relid
+------------------------+--------------------+-----------
+ regress_conflict_test1 | table | t
+(1 row)
+
+-- verify the physical table exists, its OID matches subconflictlogrelid,
+-- and it is located in the 'pg_conflict' namespace
+SELECT n.nspname, (c.oid = s.subconflictlogrelid) AS "oid_matches"
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+JOIN pg_namespace n ON c.relnamespace = n.oid
+WHERE s.subname = 'regress_conflict_test1';
+ nspname | oid_matches
+-------------+-------------
+ pg_conflict | t
+(1 row)
+
+-- check if the conflict log table has the correct schema
+SELECT a.attnum, a.attname
+FROM pg_attribute a
+JOIN pg_class c ON a.attrelid = c.oid
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0
+ ORDER BY a.attnum;
+ attnum | attname
+--------+-------------------
+ 1 | relid
+ 2 | schemaname
+ 3 | relname
+ 4 | conflict_type
+ 5 | remote_xid
+ 6 | remote_commit_lsn
+ 7 | remote_commit_ts
+ 8 | remote_origin
+ 9 | replica_identity
+ 10 | remote_tuple
+ 11 | local_conflicts
+(11 rows)
+
+--
+-- ALTER SUBSCRIPTION - conflict_log_destination state transitions
+--
+-- These tests verify the transition logic between different logging
+-- destinations, ensuring conflict log tables are created or dropped as
+-- expected
+--
+-- transition from 'log' to 'all'
+-- a new internal conflict log table should be created
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
+-- verify metadata after ALTER (destination should be 'all')
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogdest | has_relid
+------------------------+--------------------+-----------
+ regress_conflict_test2 | all | t
+(1 row)
+
+-- transition from 'all' to 'table'
+-- should NOT drop the table, only change destination string
+SELECT subconflictlogrelid AS old_relid FROM pg_subscription WHERE subname = 'regress_conflict_test2' \gset
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'table');
+SELECT subconflictlogdest, subconflictlogrelid = :old_relid AS relid_unchanged
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subconflictlogdest | relid_unchanged
+--------------------+-----------------
+ table | t
+(1 row)
+
+-- transition from 'table' to 'log'
+-- should drop the table and clear relid
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log');
+SELECT subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subconflictlogdest | subconflictlogrelid
+--------------------+---------------------
+ log | 0
+(1 row)
+
+-- verify the physical table is gone
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test2';
+ count
+-------
+ 0
+(1 row)
+
+--
+-- PUBLICATION: Verify conflict log tables are not publishable
+--
+-- pg_relation_is_publishable should return false for internal conflict log
+-- tables to prevent them from being accidentally included in publications
+--
+SELECT n.nspname, pg_relation_is_publishable(c.oid)
+FROM pg_class c
+JOIN pg_namespace n ON c.relnamespace = n.oid
+JOIN pg_subscription s ON s.subconflictlogrelid = c.oid
+WHERE s.subname = 'regress_conflict_test1';
+ nspname | pg_relation_is_publishable
+-------------+----------------------------
+ pg_conflict | f
+(1 row)
+
+--
+-- Table Protection and Lifecycle Management
+--
+-- These tests verify that:
+-- Manual DROP TABLE is disallowed
+-- DROP SUBSCRIPTION automatically reaps the table
+--
+-- re-enable table logging for verification
+ALTER SUBSCRIPTION regress_conflict_test1 SET (conflict_log_destination = 'table');
+-- We use a DO block with dynamic SQL because the internal conflict log table
+-- name contains the subscription OID, which is non-deterministic. This
+-- approach allows us to attempt the DROP and capture the expected error
+-- without hard-coding a specific OID in the expected output
+-- fail - drop table not allowed due to internal dependency
+DO $$
+BEGIN
+ EXECUTE 'DROP TABLE ' || (SELECT 'pg_conflict.pg_conflict_' || oid FROM pg_subscription WHERE subname = 'regress_conflict_test1');
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege';
+END $$;
+NOTICE: captured expected error: insufficient_privilege
+-- CLEANUP: DROP SUBSCRIPTION reaps the table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+-- Verify the table OID for reap check
+SELECT 'pg_conflict_' || oid AS internal_tablename FROM pg_subscription WHERE subname = 'regress_conflict_test1' \gset
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the conflict log table was reaped via dependency
+SELECT to_regclass(:'internal_tablename');
+ to_regclass
+-------------
+
+(1 row)
+
+--
+-- Additional Namespace and Table Protection Tests
+--
+-- Setup: Ensure we have a subscription with a conflict log table
+CREATE SUBSCRIPTION regress_conflict_protection_test CONNECTION 'dbname=regress_doesnotexist'
+ PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- Trying to ALTER the internal conflict log table
+-- This should fail because the table is system-managed
+-- As mentioned in previous test cases, we use a DO block to hide dynamic OIDs
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ RAISE NOTICE 'Attempting ALTER TABLE on internal conflict log table';
+ EXECUTE 'ALTER TABLE ' || tab_name || ' ADD COLUMN extra_info text';
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during ALTER';
+END $$;
+NOTICE: Attempting ALTER TABLE on internal conflict log table
+NOTICE: captured expected error: insufficient_privilege during ALTER
+-- Test Manual INSERT on conflict log table
+-- This should fail because the table is system-managed
+-- Hiding the OID in the error message by catching the exception
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ EXECUTE 'INSERT INTO ' || tab_name || ' (relname) VALUES (''mytest'')';
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during INSERT';
+END $$;
+NOTICE: captured expected error: insufficient_privilege during INSERT
+-- Test Manual UPDATE on conflict log table
+-- This should fail because the table is system-managed
+-- Hiding the OID in the error message by catching the exception
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ EXECUTE 'UPDATE ' || tab_name || ' SET relname = ''mytest'' ';
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during UPDATE';
+END $$;
+NOTICE: captured expected error: insufficient_privilege during UPDATE
+-- Trying to perform TRUNCATE/DELETE on the internal conflict log table
+-- This should be allowed so that user can perform cleanup
+SELECT 'pg_conflict.' || relname AS conflict_tab
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_protection_test' \gset
+TRUNCATE :conflict_tab;
+DELETE FROM :conflict_tab;
+-- Trying to create a new table manually in the pg_conflict namespace
+-- This should fail as the namespace is reserved for conflict logs tables
+CREATE TABLE pg_conflict.manual_table (id int);
+ERROR: permission denied to create "pg_conflict.manual_table"
+DETAIL: System catalog modifications are currently disallowed.
+-- Moving an existing table into the pg_conflict namespace
+-- Users should not be able to move their own tables within this namespace
+CREATE TABLE public.test_move (id int);
+ALTER TABLE public.test_move SET SCHEMA pg_conflict;
+ERROR: cannot move objects into or out of CONFLICT schema
+DROP TABLE public.test_move;
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_log_default DISABLE;
+ALTER SUBSCRIPTION regress_conflict_log_default SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_log_default;
+ALTER SUBSCRIPTION regress_conflict_empty_str DISABLE;
+ALTER SUBSCRIPTION regress_conflict_empty_str SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_empty_str;
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+ALTER SUBSCRIPTION regress_conflict_protection_test DISABLE;
+ALTER SUBSCRIPTION regress_conflict_protection_test SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_protection_test;
RESET SESSION AUTHORIZATION;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index ef0c298d2df..d2934478392 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -365,6 +365,223 @@ COMMIT;
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG DESTINATION TESTS
+--
+
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+
+-- fail - unrecognized parameter value
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
+
+-- verify subconflictlogdest is 'log' and relid is 0 (InvalidOid) for default case
+CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
+
+-- verify empty string defaults to 'log'
+CREATE SUBSCRIPTION regress_conflict_empty_str CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = '');
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
+
+-- this should generate an internal conflict log table named pg_conflict_$subid$
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+
+-- check metadata in pg_subscription: destination should be 'table' and relid valid
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- verify the physical table exists, its OID matches subconflictlogrelid,
+-- and it is located in the 'pg_conflict' namespace
+SELECT n.nspname, (c.oid = s.subconflictlogrelid) AS "oid_matches"
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+JOIN pg_namespace n ON c.relnamespace = n.oid
+WHERE s.subname = 'regress_conflict_test1';
+
+-- check if the conflict log table has the correct schema
+SELECT a.attnum, a.attname
+FROM pg_attribute a
+JOIN pg_class c ON a.attrelid = c.oid
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0
+ ORDER BY a.attnum;
+
+--
+-- ALTER SUBSCRIPTION - conflict_log_destination state transitions
+--
+-- These tests verify the transition logic between different logging
+-- destinations, ensuring conflict log tables are created or dropped as
+-- expected
+--
+-- transition from 'log' to 'all'
+-- a new internal conflict log table should be created
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
+
+-- verify metadata after ALTER (destination should be 'all')
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'all' to 'table'
+-- should NOT drop the table, only change destination string
+SELECT subconflictlogrelid AS old_relid FROM pg_subscription WHERE subname = 'regress_conflict_test2' \gset
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'table');
+SELECT subconflictlogdest, subconflictlogrelid = :old_relid AS relid_unchanged
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'table' to 'log'
+-- should drop the table and clear relid
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log');
+SELECT subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- verify the physical table is gone
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test2';
+
+--
+-- PUBLICATION: Verify conflict log tables are not publishable
+--
+-- pg_relation_is_publishable should return false for internal conflict log
+-- tables to prevent them from being accidentally included in publications
+--
+SELECT n.nspname, pg_relation_is_publishable(c.oid)
+FROM pg_class c
+JOIN pg_namespace n ON c.relnamespace = n.oid
+JOIN pg_subscription s ON s.subconflictlogrelid = c.oid
+WHERE s.subname = 'regress_conflict_test1';
+
+--
+-- Table Protection and Lifecycle Management
+--
+-- These tests verify that:
+-- Manual DROP TABLE is disallowed
+-- DROP SUBSCRIPTION automatically reaps the table
+--
+-- re-enable table logging for verification
+ALTER SUBSCRIPTION regress_conflict_test1 SET (conflict_log_destination = 'table');
+
+-- We use a DO block with dynamic SQL because the internal conflict log table
+-- name contains the subscription OID, which is non-deterministic. This
+-- approach allows us to attempt the DROP and capture the expected error
+-- without hard-coding a specific OID in the expected output
+
+-- fail - drop table not allowed due to internal dependency
+DO $$
+BEGIN
+ EXECUTE 'DROP TABLE ' || (SELECT 'pg_conflict.pg_conflict_' || oid FROM pg_subscription WHERE subname = 'regress_conflict_test1');
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege';
+END $$;
+
+-- CLEANUP: DROP SUBSCRIPTION reaps the table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+
+-- Verify the table OID for reap check
+SELECT 'pg_conflict_' || oid AS internal_tablename FROM pg_subscription WHERE subname = 'regress_conflict_test1' \gset
+
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the conflict log table was reaped via dependency
+SELECT to_regclass(:'internal_tablename');
+
+--
+-- Additional Namespace and Table Protection Tests
+--
+
+-- Setup: Ensure we have a subscription with a conflict log table
+CREATE SUBSCRIPTION regress_conflict_protection_test CONNECTION 'dbname=regress_doesnotexist'
+ PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+
+-- Trying to ALTER the internal conflict log table
+-- This should fail because the table is system-managed
+-- As mentioned in previous test cases, we use a DO block to hide dynamic OIDs
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ RAISE NOTICE 'Attempting ALTER TABLE on internal conflict log table';
+ EXECUTE 'ALTER TABLE ' || tab_name || ' ADD COLUMN extra_info text';
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during ALTER';
+END $$;
+
+-- Test Manual INSERT on conflict log table
+-- This should fail because the table is system-managed
+-- Hiding the OID in the error message by catching the exception
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ EXECUTE 'INSERT INTO ' || tab_name || ' (relname) VALUES (''mytest'')';
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during INSERT';
+END $$;
+
+-- Test Manual UPDATE on conflict log table
+-- This should fail because the table is system-managed
+-- Hiding the OID in the error message by catching the exception
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ EXECUTE 'UPDATE ' || tab_name || ' SET relname = ''mytest'' ';
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during UPDATE';
+END $$;
+
+-- Trying to perform TRUNCATE/DELETE on the internal conflict log table
+-- This should be allowed so that user can perform cleanup
+SELECT 'pg_conflict.' || relname AS conflict_tab
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_protection_test' \gset
+TRUNCATE :conflict_tab;
+DELETE FROM :conflict_tab;
+
+-- Trying to create a new table manually in the pg_conflict namespace
+-- This should fail as the namespace is reserved for conflict logs tables
+CREATE TABLE pg_conflict.manual_table (id int);
+
+-- Moving an existing table into the pg_conflict namespace
+-- Users should not be able to move their own tables within this namespace
+CREATE TABLE public.test_move (id int);
+ALTER TABLE public.test_move SET SCHEMA pg_conflict;
+DROP TABLE public.test_move;
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_log_default DISABLE;
+ALTER SUBSCRIPTION regress_conflict_log_default SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_log_default;
+
+ALTER SUBSCRIPTION regress_conflict_empty_str DISABLE;
+ALTER SUBSCRIPTION regress_conflict_empty_str SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_empty_str;
+
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+
+ALTER SUBSCRIPTION regress_conflict_protection_test DISABLE;
+ALTER SUBSCRIPTION regress_conflict_protection_test SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_protection_test;
+
RESET SESSION AUTHORIZATION;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 3f3a888fd0e..e11873ab931 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -502,6 +502,8 @@ ConditionVariableMinimallyPadded
ConditionalStack
ConfigData
ConfigVariable
+ConflictLogColumnDef
+ConflictLogDest
ConflictTupleInfo
ConflictType
ConnCacheEntry
--
2.49.0
[application/octet-stream] v24-0002-Implement-the-conflict-insertion-infrastructure-.patch (28.8K, ../../CAFiTN-t_4XvofM3an-WmykqnPE+9wf9U+o2M7p1CWd9eXkN88Q@mail.gmail.com/4-v24-0002-Implement-the-conflict-insertion-infrastructure-.patch)
download | inline diff:
From e0c4b98bb67a2467be99cf6b36c6062a282ede74 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 21 Jan 2026 18:35:00 +0530
Subject: [PATCH v24 2/3] Implement the conflict insertion infrastructure for
the conflict log table
This patch introduces the core logic to populate the conflict log table whenever
a logical replication conflict is detected. It captures the remote transaction
details along with the corresponding local state at the time of the conflict.
Handling Multi-row Conflicts: A single remote tuple may conflict with multiple
local tuples (e.g., in the case of multiple_unique_conflicts). To handle this,
the infrastructure creates a single row in the conflict log table for each
remote tuple. The details of all conflicting local rows are aggregated into a
single JSON array in the local_conflicts column.
The JSON array uses the following structured format:
[ { "xid": "1001", "commit_ts": "2025-12-25 10:00:00+05:30", "origin": "node_1",
"key": {"id": 1}, "tuple": {"id": 1, "val": "old_data"} }, ... ]
Example of querying the structured conflict data:
SELECT remote_xid, relname, remote_origin, local_conflicts[1] ->> 'xid' AS local_xid,
local_conflicts[1] ->> 'tuple' AS local_tuple
FROM myschema.conflict_log_history2;
remote_xid | relname | remote_origin | local_xid | local_tuple
------------+----------+---------------+-----------+---------------------
760 | test | pg_16406 | 771 | {"a":1,"b":10}
765 | conf_tab | pg_16406 | 775 | {"a":2,"b":2,"c":2}
---
src/backend/replication/logical/conflict.c | 563 +++++++++++++++++++--
src/backend/replication/logical/launcher.c | 1 +
src/backend/replication/logical/worker.c | 31 +-
src/include/replication/conflict.h | 3 +
src/include/replication/worker_internal.h | 7 +
src/test/subscription/t/035_conflicts.pl | 47 +-
6 files changed, 607 insertions(+), 45 deletions(-)
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 722514149fe..b305494b23d 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -15,13 +15,21 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/tableam.h"
+#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "pgstat.h"
#include "replication/conflict.h"
#include "replication/worker_internal.h"
#include "storage/lmgr.h"
+#include "utils/array.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/lsyscache.h"
+#include "utils/pg_lsn.h"
+#include "utils/json.h"
static const char *const ConflictTypeNames[] = {
[CT_INSERT_EXISTS] = "insert_exists",
@@ -34,6 +42,18 @@ static const char *const ConflictTypeNames[] = {
[CT_MULTIPLE_UNIQUE_CONFLICTS] = "multiple_unique_conflicts"
};
+/* Schema for the elements within the 'local_conflicts' JSON array */
+static const ConflictLogColumnDef LocalConflictSchema[] =
+{
+ { .attname = "xid", .atttypid = XIDOID },
+ { .attname = "commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "origin", .atttypid = TEXTOID },
+ { .attname = "key", .atttypid = JSONOID },
+ { .attname = "tuple", .atttypid = JSONOID }
+};
+
+#define MAX_LOCAL_CONFLICT_INFO_ATTRS lengthof(LocalConflictSchema)
+
static int errcode_apply_conflict(ConflictType type);
static void errdetail_apply_conflict(EState *estate,
ResultRelInfo *relinfo,
@@ -52,6 +72,27 @@ static void get_tuple_desc(EState *estate, ResultRelInfo *relinfo,
Oid indexoid);
static char *build_index_value_desc(EState *estate, Relation localrel,
TupleTableSlot *slot, Oid indexoid);
+static void build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull);
+static char *build_index_value_desc(EState *estate, Relation localrel,
+ TupleTableSlot *slot, Oid indexoid);
+static Datum tuple_table_slot_to_json_datum(TupleTableSlot *slot);
+static Datum tuple_table_slot_to_indextup_json(EState *estate,
+ Relation localrel,
+ Oid replica_index,
+ TupleTableSlot *slot);
+static TupleDesc build_conflict_tupledesc(void);
+static Datum build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples);
+static void prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot);
/*
* Get the xmin and commit timestamp data (origin and timestamp) associated
@@ -105,30 +146,90 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
ConflictType type, TupleTableSlot *searchslot,
TupleTableSlot *remoteslot, List *conflicttuples)
{
- Relation localrel = relinfo->ri_RelationDesc;
- StringInfoData err_detail;
+ Relation localrel = relinfo->ri_RelationDesc;
+ ConflictLogDest dest;
+ Relation conflictlogrel;
+ bool log_dest_clt;
+ bool log_dest_logfile;
- initStringInfo(&err_detail);
+ pgstat_report_subscription_conflict(MySubscription->oid, type);
- /* Form errdetail message by combining conflicting tuples information. */
- foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
- errdetail_apply_conflict(estate, relinfo, type, searchslot,
- conflicttuple->slot, remoteslot,
- conflicttuple->indexoid,
- conflicttuple->xmin,
- conflicttuple->origin,
- conflicttuple->ts,
- &err_detail);
+ /*
+ * Get the conflict log destination. Also, (if there is one) return the
+ * CLT relation already opened and ready for insertion.
+ */
+ conflictlogrel = GetConflictLogDestAndTable(&dest);
- pgstat_report_subscription_conflict(MySubscription->oid, type);
+ log_dest_clt = ((dest & CONFLICT_LOG_DEST_TABLE) != 0);
+ log_dest_logfile = ((dest & CONFLICT_LOG_DEST_LOG) != 0);
+
+ /* Insert to table if requested. */
+ if (log_dest_clt)
+ {
+ Assert(conflictlogrel != NULL);
+
+ /*
+ * Prepare the conflict log tuple. If the error level is below ERROR,
+ * insert it immediately. Otherwise, defer the insertion to a new
+ * transaction after the current one aborts, ensuring the insertion of
+ * the log tuple is not rolled back.
+ */
+ prepare_conflict_log_tuple(estate,
+ relinfo->ri_RelationDesc,
+ conflictlogrel,
+ type,
+ searchslot,
+ conflicttuples,
+ remoteslot);
+ if (elevel < ERROR)
+ InsertConflictLogTuple(conflictlogrel);
+
+ if (!log_dest_logfile)
+ {
+ /*
+ * Not logging conflict details to the server log; Report the error
+ * msg but omit raw tuple data from server logs since it's already
+ * captured in the conflict log table.
+ */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail("Conflict details are logged to the conflict log table: %s",
+ RelationGetRelationName(conflictlogrel)));
+ }
+
+ table_close(conflictlogrel, RowExclusiveLock);
+ }
- ereport(elevel,
- errcode_apply_conflict(type),
- errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
- get_namespace_name(RelationGetNamespace(localrel)),
- RelationGetRelationName(localrel),
- ConflictTypeNames[type]),
- errdetail_internal("%s", err_detail.data));
+ /* Log into the server log if requested. */
+ if (log_dest_logfile)
+ {
+ StringInfoData err_detail;
+
+ initStringInfo(&err_detail);
+
+ /* Form errdetail message by combining conflicting tuples information. */
+ foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
+ errdetail_apply_conflict(estate, relinfo, type, searchslot,
+ conflicttuple->slot, remoteslot,
+ conflicttuple->indexoid,
+ conflicttuple->xmin,
+ conflicttuple->origin,
+ conflicttuple->ts,
+ &err_detail);
+
+ /* Standard reporting with full internal details. */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail_internal("%s", err_detail.data));
+ }
}
/*
@@ -162,6 +263,64 @@ InitConflictIndexes(ResultRelInfo *relInfo)
relInfo->ri_onConflictArbiterIndexes = uniqueIndexes;
}
+/*
+ * GetConflictLogDestAndTable
+ *
+ * Fetches conflict logging metadata from the cached MySubscription pointer.
+ * Sets the destination enum in *log_dest and, if applicable, opens and
+ * returns the relation handle for the conflict log table.
+ */
+Relation
+GetConflictLogDestAndTable(ConflictLogDest *log_dest)
+{
+ Oid conflictlogrelid;
+ Relation conflictlogrel = NULL;
+
+ /*
+ * Convert the text log destination to the internal enum. MySubscription
+ * already contains the data from pg_subscription.
+ */
+ *log_dest = GetLogDestination(MySubscription->conflictlogdest);
+
+ /* Quick exit if a conflict log table was not requested. */
+ if ((*log_dest & CONFLICT_LOG_DEST_TABLE) == 0)
+ return NULL;
+
+ conflictlogrelid = MySubscription->conflictlogrelid;
+
+ Assert(OidIsValid(conflictlogrelid));
+
+ conflictlogrel = table_open(conflictlogrelid, RowExclusiveLock);
+ if (conflictlogrel == NULL)
+ elog(ERROR, "could not open conflict log table (OID %u)",
+ conflictlogrelid);
+
+ return conflictlogrel;
+}
+
+/*
+ * InsertConflictLogTuple
+ *
+ * Insert conflict log tuple into the conflict log table. It uses
+ * HEAP_INSERT_NO_LOGICAL to explicitly block logical decoding of the tuple
+ * inserted into the conflict log table.
+ */
+void
+InsertConflictLogTuple(Relation conflictlogrel)
+{
+ int options = HEAP_INSERT_NO_LOGICAL;
+
+ /* A valid tuple must be prepared and stored in MyLogicalRepWorker. */
+ Assert(MyLogicalRepWorker->conflict_log_tuple != NULL);
+
+ heap_insert(conflictlogrel, MyLogicalRepWorker->conflict_log_tuple,
+ GetCurrentCommandId(true), options, NULL);
+
+ /* Free conflict log tuple. */
+ heap_freetuple(MyLogicalRepWorker->conflict_log_tuple);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+}
+
/*
* Add SQLSTATE error code to the current conflict report.
*/
@@ -522,6 +681,40 @@ get_tuple_desc(EState *estate, ResultRelInfo *relinfo, ConflictType type,
}
}
+/*
+ * Helper function to extract the "raw" index key Datums and their null flags
+ * from a TupleTableSlot, given an already open index descriptor.
+ * This is the reusable core logic.
+ */
+static void
+build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull)
+{
+ TupleTableSlot *tableslot = slot;
+
+ /*
+ * If the slot is a virtual slot, copy it into a heap tuple slot as
+ * FormIndexDatum only works with heap tuple slots.
+ */
+ if (TTS_IS_VIRTUAL(slot))
+ {
+ /* Slot is created within the EState's tuple table */
+ tableslot = table_slot_create(localrel, &estate->es_tupleTable);
+ tableslot = ExecCopySlot(tableslot, slot);
+ }
+
+ /*
+ * Initialize ecxt_scantuple for potential use in FormIndexDatum
+ */
+ GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+
+ /* Form the index datums */
+ FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values,
+ isnull);
+}
+
/*
* Helper functions to construct a string describing the contents of an index
* entry. See BuildIndexValueDescription for details.
@@ -537,41 +730,325 @@ build_index_value_desc(EState *estate, Relation localrel, TupleTableSlot *slot,
Relation indexDesc;
Datum values[INDEX_MAX_KEYS];
bool isnull[INDEX_MAX_KEYS];
- TupleTableSlot *tableslot = slot;
- if (!tableslot)
+ if (!slot)
return NULL;
Assert(CheckRelationOidLockedByMe(indexoid, RowExclusiveLock, true));
indexDesc = index_open(indexoid, NoLock);
- /*
- * If the slot is a virtual slot, copy it into a heap tuple slot as
- * FormIndexDatum only works with heap tuple slots.
- */
- if (TTS_IS_VIRTUAL(slot))
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+
+ index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+
+ index_close(indexDesc, NoLock);
+
+ return index_value;
+}
+
+/*
+ * tuple_table_slot_to_json_datum
+ *
+ * Helper function to convert a TupleTableSlot to JSON.
+ */
+static Datum
+tuple_table_slot_to_json_datum(TupleTableSlot *slot)
+{
+ HeapTuple tuple;
+ Datum datum;
+ Datum json;
+
+ Assert(slot != NULL);
+
+ tuple = ExecCopySlotHeapTuple(slot);
+ datum = heap_copy_tuple_as_datum(tuple, slot->tts_tupleDescriptor);
+
+ json = DirectFunctionCall1(row_to_json, datum);
+ heap_freetuple(tuple);
+
+ return json;
+}
+
+/*
+ * tuple_table_slot_to_indextup_json
+ *
+ * Fetch replica identity key from the tuple table slot and convert into a
+ * JSON datum.
+ */
+static Datum
+tuple_table_slot_to_indextup_json(EState *estate, Relation localrel,
+ Oid indexid, TupleTableSlot *slot)
+{
+ Relation indexDesc;
+ Datum values[INDEX_MAX_KEYS];
+ bool isnull[INDEX_MAX_KEYS];
+ HeapTuple tuple;
+ TupleDesc tupdesc;
+ Datum datum;
+
+ Assert(slot != NULL);
+
+ Assert(CheckRelationOidLockedByMe(indexid, RowExclusiveLock, true));
+
+ indexDesc = index_open(indexid, NoLock);
+
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+ tupdesc = RelationGetDescr(indexDesc);
+
+ /* Bless the tupdesc so it can be looked up by row_to_json. */
+ BlessTupleDesc(tupdesc);
+
+ /* Form the replica identity tuple. */
+ tuple = heap_form_tuple(tupdesc, values, isnull);
+ datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ index_close(indexDesc, NoLock);
+ heap_freetuple(tuple);
+
+ /* Convert to a JSON datum. */
+ return DirectFunctionCall1(row_to_json, datum);
+}
+
+/*
+ * build_conflict_tupledesc
+ *
+ * Build and bless a tuple descriptor for the internal conflict log table
+ * based on the predefined LocalConflictSchema.
+ */
+static TupleDesc
+build_conflict_tupledesc(void)
+{
+ TupleDesc tupdesc;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ for (int i = 0; i < MAX_LOCAL_CONFLICT_INFO_ATTRS; i++)
+ TupleDescInitEntry(tupdesc, (AttrNumber) (i + 1),
+ LocalConflictSchema[i].attname,
+ LocalConflictSchema[i].atttypid,
+ -1, 0);
+
+ BlessTupleDesc(tupdesc);
+
+ return tupdesc;
+}
+
+/*
+ * Builds the local conflicts JSON array column from the list of
+ * ConflictTupleInfo objects.
+ *
+ * Example output structure:
+ * [ { "xid": "1001", "commit_ts": "...", "origin": "...", "tuple": {...} }, ... ]
+ */
+static Datum
+build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples)
+{
+ ListCell *lc;
+ List *json_datums = NIL;
+ Datum *json_datum_array;
+ bool *json_null_array;
+ Datum json_array_datum;
+ int num_conflicts;
+ int i;
+ int16 typlen;
+ bool typbyval;
+ char typalign;
+ TupleDesc tupdesc;
+
+ /* Build local conflicts tuple descriptor. */
+ tupdesc = build_conflict_tupledesc();
+
+ /* Process local conflict tuple list and prepare an array of JSON. */
+ foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
{
- tableslot = table_slot_create(localrel, &estate->es_tupleTable);
- tableslot = ExecCopySlot(tableslot, slot);
+ Datum values[MAX_LOCAL_CONFLICT_INFO_ATTRS] = {0};
+ bool nulls[MAX_LOCAL_CONFLICT_INFO_ATTRS] = {0};
+ char *origin_name = NULL;
+ HeapTuple tuple;
+ Datum json_datum;
+ int attno;
+
+ attno = 0;
+ values[attno++] = TransactionIdGetDatum(conflicttuple->xmin);
+
+ if (conflicttuple->ts)
+ values[attno++] = TimestampTzGetDatum(conflicttuple->ts);
+ else
+ nulls[attno++] = true;
+
+ if (conflicttuple->origin != InvalidRepOriginId)
+ replorigin_by_oid(conflicttuple->origin, true, &origin_name);
+
+ /* Store empty string if origin name for the tuple is NULL. */
+ if (origin_name != NULL)
+ values[attno++] = CStringGetTextDatum(origin_name);
+ else
+ nulls[attno++] = true;
+
+ /*
+ * Add the conflicting key values in the case of a unique constraint
+ * violation.
+ */
+ if (conflict_type == CT_INSERT_EXISTS ||
+ conflict_type == CT_UPDATE_EXISTS ||
+ conflict_type == CT_MULTIPLE_UNIQUE_CONFLICTS)
+ {
+ Oid indexoid = conflicttuple->indexoid;
+
+ Assert(OidIsValid(indexoid) && conflicttuple->slot &&
+ CheckRelationOidLockedByMe(indexoid, RowExclusiveLock,
+ true));
+ values[attno++] =
+ tuple_table_slot_to_indextup_json(estate, rel,
+ indexoid,
+ conflicttuple->slot);
+ }
+ else
+ nulls[attno++] = true;
+
+ /* Convert conflicting tuple to JSON datum. */
+ if (conflicttuple->slot)
+ values[attno] = tuple_table_slot_to_json_datum(conflicttuple->slot);
+ else
+ nulls[attno] = true;
+
+ Assert(attno + 1 == MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ tuple = heap_form_tuple(tupdesc, values, nulls);
+
+ json_datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ /*
+ * Build the higher level JSON datum in format described in function
+ * header.
+ */
+ json_datum = DirectFunctionCall1(row_to_json, json_datum);
+
+ /* Done with the temporary tuple. */
+ heap_freetuple(tuple);
+
+ /* Add to the array element. */
+ json_datums = lappend(json_datums, (void *) json_datum);
}
- /*
- * Initialize ecxt_scantuple for potential use in FormIndexDatum when
- * index expressions are present.
- */
- GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+ num_conflicts = list_length(json_datums);
- /*
- * The values/nulls arrays passed to BuildIndexValueDescription should be
- * the results of FormIndexDatum, which are the "raw" input to the index
- * AM.
- */
- FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values, isnull);
+ json_datum_array = palloc_array(Datum, num_conflicts);
+ json_null_array = palloc0_array(bool, num_conflicts);
- index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+ i = 0;
+ foreach(lc, json_datums)
+ {
+ json_datum_array[i] = (Datum) lfirst(lc);
+ i++;
+ }
- index_close(indexDesc, NoLock);
+ /* Construct the JSON array Datum. */
+ get_typlenbyvalalign(JSONOID, &typlen, &typbyval, &typalign);
+ json_array_datum = PointerGetDatum(construct_array(json_datum_array,
+ num_conflicts,
+ JSONOID,
+ typlen,
+ typbyval,
+ typalign));
+ pfree(json_datum_array);
+ pfree(json_null_array);
+
+ return json_array_datum;
+}
- return index_value;
+/*
+ * prepare_conflict_log_tuple
+ *
+ * This routine prepares a tuple detailing a conflict encountered during
+ * logical replication. The prepared tuple will be stored in
+ * MyLogicalRepWorker->conflict_log_tuple which should be inserted into the
+ * conflict log table by calling InsertConflictLogTuple.
+ */
+static void
+prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot)
+{
+ Datum values[MAX_CONFLICT_ATTR_NUM] = {0};
+ bool nulls[MAX_CONFLICT_ATTR_NUM] = {0};
+ int attno;
+ char *remote_origin = NULL;
+ MemoryContext oldctx;
+
+ Assert(MyLogicalRepWorker->conflict_log_tuple == NULL);
+
+ /* Populate the values and nulls arrays. */
+ attno = 0;
+ values[attno++] = ObjectIdGetDatum(RelationGetRelid(rel));
+
+ values[attno++] =
+ CStringGetTextDatum(get_namespace_name(RelationGetNamespace(rel)));
+
+ values[attno++] = CStringGetTextDatum(RelationGetRelationName(rel));
+
+ values[attno++] = CStringGetTextDatum(ConflictTypeNames[conflict_type]);
+
+ if (TransactionIdIsValid(remote_xid))
+ values[attno++] = TransactionIdGetDatum(remote_xid);
+ else
+ nulls[attno++] = true;
+
+ values[attno++] = LSNGetDatum(remote_final_lsn);
+
+ if (remote_commit_ts > 0)
+ values[attno++] = TimestampTzGetDatum(remote_commit_ts);
+ else
+ nulls[attno++] = true;
+
+ if (replorigin_session_origin != InvalidRepOriginId)
+ replorigin_by_oid(replorigin_session_origin, true, &remote_origin);
+
+ if (remote_origin != NULL)
+ values[attno++] = CStringGetTextDatum(remote_origin);
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(searchslot))
+ {
+ Oid replica_index = GetRelationIdentityOrPK(rel);
+
+ /*
+ * If the table has a valid replica identity index, build the index
+ * JSON datum from key value. Otherwise, construct it from the complete
+ * tuple in REPLICA IDENTITY FULL cases.
+ */
+ if (OidIsValid(replica_index))
+ values[attno++] = tuple_table_slot_to_indextup_json(estate, rel,
+ replica_index,
+ searchslot);
+ else
+ values[attno++] = tuple_table_slot_to_json_datum(searchslot);
+ }
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(remoteslot))
+ values[attno++] = tuple_table_slot_to_json_datum(remoteslot);
+ else
+ nulls[attno++] = true;
+
+ values[attno] = build_local_conflicts_json_array(estate, rel,
+ conflict_type,
+ conflicttuples);
+
+ Assert(attno + 1 == MAX_CONFLICT_ATTR_NUM);
+
+ oldctx = MemoryContextSwitchTo(ApplyContext);
+ MyLogicalRepWorker->conflict_log_tuple =
+ heap_form_tuple(RelationGetDescr(conflictlogrel), values, nulls);
+ MemoryContextSwitchTo(oldctx);
}
diff --git a/src/backend/replication/logical/launcher.c b/src/backend/replication/logical/launcher.c
index 3ed86480be2..2dda5a44218 100644
--- a/src/backend/replication/logical/launcher.c
+++ b/src/backend/replication/logical/launcher.c
@@ -477,6 +477,7 @@ retry:
worker->oldest_nonremovable_xid = retain_dead_tuples
? MyReplicationSlot->data.xmin
: InvalidTransactionId;
+ worker->conflict_log_tuple = NULL;
worker->last_lsn = InvalidXLogRecPtr;
TIMESTAMP_NOBEGIN(worker->last_send_time);
TIMESTAMP_NOBEGIN(worker->last_recv_time);
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index ad281e7069b..d4be1122603 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -482,7 +482,9 @@ static bool MySubscriptionValid = false;
static List *on_commit_wakeup_workers_subids = NIL;
bool in_remote_transaction = false;
-static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+TransactionId remote_xid = InvalidTransactionId;
+TimestampTz remote_commit_ts = 0;
/* fields valid only when processing streamed transaction */
static bool in_streamed_transaction = false;
@@ -1219,6 +1221,8 @@ apply_handle_begin(StringInfo s)
set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
remote_final_lsn = begin_data.final_lsn;
+ remote_commit_ts = begin_data.committime;
+ remote_xid = begin_data.xid;
maybe_start_skipping_changes(begin_data.final_lsn);
@@ -1745,6 +1749,10 @@ apply_handle_stream_start(StringInfo s)
/* extract XID of the top-level transaction */
stream_xid = logicalrep_read_stream_start(s, &first_segment);
+ remote_xid = stream_xid;
+ remote_final_lsn = InvalidXLogRecPtr;
+ remote_commit_ts = 0;
+
if (!TransactionIdIsValid(stream_xid))
ereport(ERROR,
(errcode(ERRCODE_PROTOCOL_VIOLATION),
@@ -5609,6 +5617,27 @@ start_apply(XLogRecPtr origin_startpos)
pgstat_report_subscription_error(MySubscription->oid,
MyLogicalRepWorker->type);
+ /*
+ * Insert any pending conflict log tuple under a new transaction.
+ */
+ if (MyLogicalRepWorker->conflict_log_tuple != NULL)
+ {
+ Relation conflictlogrel;
+ ConflictLogDest dest;
+
+ StartTransactionCommand();
+ PushActiveSnapshot(GetTransactionSnapshot());
+
+ /* Open conflict log table and insert the tuple. */
+ conflictlogrel = GetConflictLogDestAndTable(&dest);
+ Assert((dest & CONFLICT_LOG_DEST_TABLE) != 0);
+ InsertConflictLogTuple(conflictlogrel);
+ table_close(conflictlogrel, RowExclusiveLock);
+
+ PopActiveSnapshot();
+ CommitTransactionCommand();
+ }
+
PG_RE_THROW();
}
}
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index 31facc1ec7e..dbf259b8d01 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -144,4 +144,7 @@ extern void ReportApplyConflict(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *remoteslot,
List *conflicttuples);
extern void InitConflictIndexes(ResultRelInfo *relInfo);
+extern Relation GetConflictLogDestAndTable(ConflictLogDest *log_dest);
+extern void InsertConflictLogTuple(Relation conflictlogrel);
+extern bool ValidateConflictLogTable(Relation rel);
#endif
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index c1285fdd1bc..5bedfc5450f 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -101,6 +101,9 @@ typedef struct LogicalRepWorker
*/
TransactionId oldest_nonremovable_xid;
+ /* A conflict log tuple that is prepared but not yet inserted. */
+ HeapTuple conflict_log_tuple;
+
/* Stats. */
XLogRecPtr last_lsn;
TimestampTz last_send_time;
@@ -256,6 +259,10 @@ extern PGDLLIMPORT bool InitializingApplyWorker;
extern PGDLLIMPORT List *table_states_not_ready;
+extern XLogRecPtr remote_final_lsn;
+extern TimestampTz remote_commit_ts;
+extern TransactionId remote_xid;
+
extern void logicalrep_worker_attach(int slot);
extern LogicalRepWorker *logicalrep_worker_find(LogicalRepWorkerType wtype,
Oid subid, Oid relid,
diff --git a/src/test/subscription/t/035_conflicts.pl b/src/test/subscription/t/035_conflicts.pl
index 426ad74cf33..32925bd49ca 100644
--- a/src/test/subscription/t/035_conflicts.pl
+++ b/src/test/subscription/t/035_conflicts.pl
@@ -50,7 +50,7 @@ $node_subscriber->safe_psql(
'postgres',
"CREATE SUBSCRIPTION sub_tab
CONNECTION '$publisher_connstr application_name=$appname'
- PUBLICATION pub_tab;");
+ PUBLICATION pub_tab WITH (conflict_log_destination=all)");
# Wait for initial table sync to finish
$node_subscriber->wait_for_subscription_sync($node_publisher, $appname);
@@ -84,10 +84,35 @@ $node_subscriber->wait_for_log(
.*Key already exists in unique index \"conf_tab_c_key\", modified in transaction .*: key \(c\)=\(4\), local row \(4, 4, 4\)./,
$log_offset);
+# Verify the contents of the Conflict Log Table (CLT)
+# This section ensures that the clt contains the expected
+# type and specific key data.
+my $subid = $node_subscriber->safe_psql('postgres',
+ "SELECT oid FROM pg_subscription WHERE subname = 'sub_tab';");
+my $clt = "pg_conflict.pg_conflict_$subid";
+
+# Wait for the conflict to be logged in the CLT
+my $log_check = $node_subscriber->poll_query_until(
+ 'postgres',
+ "SELECT count(*) > 0 FROM $clt;"
+);
+
+my $conflict_check = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) >= 1 FROM $clt WHERE conflict_type = 'multiple_unique_conflicts';");
+is($conflict_check, 't', 'Verified multiple_unique_conflicts logged into conflict log table');
+
+my $json_query = "SELECT local_conflicts FROM $clt;";
+my $raw_json = $node_subscriber->safe_psql('postgres', $json_query);
+
+# Verify that '2' is present inside the JSON structure using a regex
+# This matches the key/value pattern for "a": 2
+like($raw_json, qr/\\"a\\":2/, 'Verified that key 2 exists in the local_conflicts');
+
pass('multiple_unique_conflicts detected during insert');
# Truncate table to get rid of the error
$node_subscriber->safe_psql('postgres', "TRUNCATE conf_tab;");
+$node_subscriber->safe_psql('postgres', "DELETE FROM $clt");
##################################################
# Test multiple_unique_conflicts due to UPDATE
@@ -114,6 +139,26 @@ $node_subscriber->wait_for_log(
.*Key already exists in unique index \"conf_tab_c_key\", modified in transaction .*: key \(c\)=\(8\), local row \(8, 8, 8\)./,
$log_offset);
+# Verify the contents of the Conflict Log Table (CLT)
+# This section ensures that the CLT contains the expected
+# type and specific key data.
+
+# Wait for the conflict to be logged in the CLT
+$log_check = $node_subscriber->poll_query_until(
+ 'postgres',
+ "SELECT count(*) > 0 FROM $clt;"
+);
+
+$conflict_check = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) >= 1 FROM $clt WHERE conflict_type = 'multiple_unique_conflicts';");
+is($conflict_check, 't', 'Verified multiple_unique_conflicts logged into conflict log table');
+
+$raw_json = $node_subscriber->safe_psql('postgres', $json_query);
+
+# Verify that '6' is present inside the JSON structure using a regex
+# This matches the key/value pattern for "a": 6
+like($raw_json, qr/\\"a\\":6/, 'Verified that key 6 exists in the local_conflicts');
+
pass('multiple_unique_conflicts detected during update');
# Truncate table to get rid of the error
--
2.49.0
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-27 03:08 Peter Smith <smithpb2250@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: Peter Smith @ 2026-01-27 03:08 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Hi Dilip.
Some comments for the first 2 patches:
//////////
v24-00001
//////////
1.
+ /*
+ * Conflict log tables are managed by the system to record logical
+ * replication conflicts. We do not allow locking rows in CONFLICT
+ * relations.
+ */
+ if (IsConflictNamespace(RelationGetNamespace(rel)))
+ ereport(ERROR,
+ (errcode(ERRCODE_WRONG_OBJECT_TYPE),
+ errmsg("cannot lock rows in CONFLICT relation \"%s\"",
+ RelationGetRelationName(rel))));
AFAIK, this "CONFLICT relation" terminology is not used anywhere else.
Why not just call it what it is:
e.g.
cannot lock rows in conflict log table \"%s\"
~
OTOH, if you were attempting to future-proof the message for different
kinds of relations in the 'pg_conflict' namespace, I still felt it
might be better to refer to 'pg_conflict' instead of CONFLICT:
e.g.
cannot lock rows in 'pg_conflict' relation \"%s\"
//////////
v24-0002
//////////
1.
+static char *build_index_value_desc(EState *estate, Relation localrel,
+ TupleTableSlot *slot, Oid indexoid);
Declared twice?
======
Kind Regards,
Peter Smith.
Fujitsu Australia.
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-27 03:53 Dilip Kumar <dilipbalaut@gmail.com>
parent: Peter Smith <smithpb2250@gmail.com>
0 siblings, 2 replies; 912+ messages in thread
From: Dilip Kumar @ 2026-01-27 03:53 UTC (permalink / raw)
To: Peter Smith <smithpb2250@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, Jan 27, 2026 at 8:39 AM Peter Smith <smithpb2250@gmail.com> wrote:
>
> Hi Dilip.
>
> Some comments for the first 2 patches:
>
> //////////
> v24-00001
> //////////
>
> 1.
> + /*
> + * Conflict log tables are managed by the system to record logical
> + * replication conflicts. We do not allow locking rows in CONFLICT
> + * relations.
> + */
> + if (IsConflictNamespace(RelationGetNamespace(rel)))
> + ereport(ERROR,
> + (errcode(ERRCODE_WRONG_OBJECT_TYPE),
> + errmsg("cannot lock rows in CONFLICT relation \"%s\"",
> + RelationGetRelationName(rel))));
>
> AFAIK, this "CONFLICT relation" terminology is not used anywhere else.
>
> Why not just call it what it is:
>
> e.g.
> cannot lock rows in conflict log table \"%s\"
>
> ~
>
> OTOH, if you were attempting to future-proof the message for different
> kinds of relations in the 'pg_conflict' namespace, I still felt it
> might be better to refer to 'pg_conflict' instead of CONFLICT:
>
> e.g.
> cannot lock rows in 'pg_conflict' relation \"%s\"
I prefer conflict log tables for consistency with other places.
> //////////
> v24-0002
> //////////
>
> 1.
> +static char *build_index_value_desc(EState *estate, Relation localrel,
> + TupleTableSlot *slot, Oid indexoid);
>
> Declared twice?
Removed duplicate.
Also fixed all pending doc comments.
--
Regards,
Dilip Kumar
Google
Attachments:
[application/octet-stream] v25-0003-Doccumentation-patch.patch (10.8K, ../../CAFiTN-uqNN9S_hRuda_th5MEpywa15g+XO00yM6tNJ-spGRRJw@mail.gmail.com/2-v25-0003-Doccumentation-patch.patch)
download | inline diff:
From a09bd63c6ca086aa784b8e586914e4e3e9f27275 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 21 Jan 2026 18:37:25 +0530
Subject: [PATCH v25 3/3] Doccumentation patch
---
doc/src/sgml/logical-replication.sgml | 109 +++++++++++++++++++++-
doc/src/sgml/ref/alter_subscription.sgml | 14 ++-
doc/src/sgml/ref/create_subscription.sgml | 47 ++++++++++
3 files changed, 167 insertions(+), 3 deletions(-)
diff --git a/doc/src/sgml/logical-replication.sgml b/doc/src/sgml/logical-replication.sgml
index 5028fe9af09..33cf5ccf50b 100644
--- a/doc/src/sgml/logical-replication.sgml
+++ b/doc/src/sgml/logical-replication.sgml
@@ -289,6 +289,19 @@
option of <command>CREATE SUBSCRIPTION</command> for details.
</para>
+ <para>
+ Conflicts that occur during replication are, by default, logged as plain text
+ in the server log, which can make automated monitoring and analysis difficult.
+ The <command>CREATE SUBSCRIPTION</command> command provides the
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination">
+ <literal>conflict_log_destination</literal></link> option to record detailed
+ conflict information in a structured, queryable format. When this parameter
+ is set to <literal>table</literal> or <literal>all</literal>, the system
+ automatically manages a dedicated conflict log table, which is created and
+ dropped along with the subscription. This significantly improves post-mortem
+ analysis and operational visibility of the replication setup.
+ </para>
+
<sect2 id="logical-replication-subscription-slot">
<title>Logical Replication Slot Management</title>
@@ -2118,7 +2131,94 @@ Publications:
</para>
<para>
- The log format for logical replication conflicts is as follows:
+ The <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>
+ parameter can automatically creates a dedicated conflict log table. This table is created in the dedicated
+ <literal>pg_conflict</literal> namespace. The name of the conflict log table
+ is <literal>pg_conflict_<subid></literal>. The predefined schema of this table is
+ detailed in
+ <xref linkend="logical-replication-conflict-log-schema"/>.
+ </para>
+
+ <table id="logical-replication-conflict-log-schema">
+ <title>Conflict Log Table Schema</title>
+ <tgroup cols="3">
+ <thead>
+ <row>
+ <entry>Column</entry>
+ <entry>Type</entry>
+ <entry>Description</entry>
+ </row>
+ </thead>
+ <tbody>
+ <row>
+ <entry><literal>relid</literal></entry>
+ <entry><type>oid</type></entry>
+ <entry>The OID of the local table where the conflict occurred.</entry>
+ </row>
+ <row>
+ <entry><literal>schemaname</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The schema name of the conflicting table.</entry>
+ </row>
+ <row>
+ <entry><literal>relname</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The name of the conflicting table.</entry>
+ </row>
+ <row>
+ <entry><literal>conflict_type</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The type of conflict that occurred (e.g., <literal>insert_exists</literal>).</entry>
+ </row>
+ <row>
+ <entry><literal>remote_xid</literal></entry>
+ <entry><type>xid</type></entry>
+ <entry>The remote transaction ID that caused the conflict.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_commit_lsn</literal></entry>
+ <entry><type>pg_lsn</type></entry>
+ <entry>The final LSN of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_commit_ts</literal></entry>
+ <entry><type>timestamptz</type></entry>
+ <entry>The remote commit timestamp of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_origin</literal></entry>
+ <entry><type>text</type></entry>
+ <entry>The origin of the remote transaction.</entry>
+ </row>
+ <row>
+ <entry><literal>remote_tuple</literal></entry>
+ <entry><type>json</type></entry>
+ <entry>The JSON representation of the incoming remote row that caused the conflict.</entry>
+ </row>
+ <row>
+ <entry><literal>local_conflicts</literal></entry>
+ <entry><type>json[]</type></entry>
+ <entry>
+ An array of JSON objects representing the local state for each conflict attempt.
+ Each object includes the local transaction ID (<literal>xid</literal>), commit
+ timestamp (<literal>commit_ts</literal>), origin (<literal>origin</literal>),
+ conflicting key data (<literal>key</literal>), and the full local row
+ image (<literal>tuple</literal>).
+ </entry>
+ </row>
+ </tbody>
+ </tgroup>
+ </table>
+
+ <para>
+ The conflicting row data, including the incoming remote row (<literal>remote_tuple</literal>)
+ and the associated local conflict details (<literal>local_conflicts</literal>), is stored in
+ <type>JSON</type> formats, for flexible querying and analysis.
+ </para>
+
+ <para>
+ If <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>
+ is set to log conflicts to the server log, the following format is used:
<synopsis>
LOG: conflict detected on relation "<replaceable>schemaname</replaceable>.<replaceable>tablename</replaceable>": conflict=<replaceable>conflict_type</replaceable>
DETAIL: <replaceable class="parameter">detailed_explanation</replaceable>[: <replaceable class="parameter">detail_values</replaceable> [, ... ]].
@@ -2411,6 +2511,13 @@ CONTEXT: processing remote data for replication origin "pg_16395" during "INSER
key or replica identity defined for it.
</para>
</listitem>
+
+ <listitem>
+ <para>
+ Conflict log tables (see <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link> parameter)
+ are never published, even when using FOR ALL TABLES in a publication.
+ </para>
+ </listitem>
</itemizedlist>
</sect1>
diff --git a/doc/src/sgml/ref/alter_subscription.sgml b/doc/src/sgml/ref/alter_subscription.sgml
index 27c06439f4f..2de2c3c52fb 100644
--- a/doc/src/sgml/ref/alter_subscription.sgml
+++ b/doc/src/sgml/ref/alter_subscription.sgml
@@ -280,8 +280,9 @@ ALTER SUBSCRIPTION <replaceable class="parameter">name</replaceable> RENAME TO <
<link linkend="sql-createsubscription-params-with-origin"><literal>origin</literal></link>,
<link linkend="sql-createsubscription-params-with-failover"><literal>failover</literal></link>,
<link linkend="sql-createsubscription-params-with-two-phase"><literal>two_phase</literal></link>,
- <link linkend="sql-createsubscription-params-with-retain-dead-tuples"><literal>retain_dead_tuples</literal></link>, and
- <link linkend="sql-createsubscription-params-with-max-retention-duration"><literal>max_retention_duration</literal></link>.
+ <link linkend="sql-createsubscription-params-with-retain-dead-tuples"><literal>retain_dead_tuples</literal></link>,
+ <link linkend="sql-createsubscription-params-with-max-retention-duration"><literal>max_retention_duration</literal></link> and,
+ <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>.
Only a superuser can set <literal>password_required = false</literal>.
</para>
@@ -339,6 +340,15 @@ ALTER SUBSCRIPTION <replaceable class="parameter">name</replaceable> RENAME TO <
<quote><literal>pg_conflict_detection</literal></quote>, created to retain
dead tuples for conflict detection, will be dropped.
</para>
+
+ <para>
+ When the <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>
+ parameter is set to <literal>table</literal> or <literal>all</literal>, the system
+ automatically creates the internal conflict log table if it does not already
+ exist. Conversely, if the destination is changed to
+ <literal>log</literal>, logging to the table stops and the internal
+ table is automatically dropped.
+ </para>
</listitem>
</varlistentry>
diff --git a/doc/src/sgml/ref/create_subscription.sgml b/doc/src/sgml/ref/create_subscription.sgml
index b7dd361294b..3fdb52a0800 100644
--- a/doc/src/sgml/ref/create_subscription.sgml
+++ b/doc/src/sgml/ref/create_subscription.sgml
@@ -247,6 +247,53 @@ CREATE SUBSCRIPTION <replaceable class="parameter">subscription_name</replaceabl
</listitem>
</varlistentry>
+ <varlistentry id="sql-createsubscription-params-with-conflict-log-destination">
+ <term><literal>conflict_log_destination</literal> (<type>enum</type>)</term>
+ <listitem>
+ <para>
+ Specifies the destination for recording logical replication conflicts.
+ </para>
+ <para>
+ The available destinations are:
+ <itemizedlist>
+ <listitem>
+ <para>
+ <literal>log</literal>: Conflict details are recorded in the server log.
+ This is the default behavior.
+ </para>
+ </listitem>
+ <listitem>
+ <para>
+ <literal>table</literal>: The system automatically creates a structured table
+ named <literal>pg_conflict_<subid></literal> in the
+ <literal>pg_conflict</literal> schema. This allows for easy querying and
+ analysis of conflicts.
+ </para>
+ <caution>
+ <para>
+ The internal conflict log table is strictly tied to the lifecycle of the
+ subscription or the <literal>conflict_log_destination</literal> setting. If
+ the subscription is dropped, or if the destination is changed to
+ <literal>log</literal>, the table and all its recorded conflict data are
+ <emphasis>permanently deleted</emphasis>.
+ </para>
+ <para>
+ If post-mortem analysis may be needed, back up the conflict log table before
+ removing the subscription.
+ </para>
+ </caution>
+ </listitem>
+ <listitem>
+ <para>
+ <literal>all</literal>: Records conflict details to both destinations
+ <literal>log</literal> and <literal>table</literal>.
+ </para>
+ </listitem>
+ </itemizedlist>
+ </para>
+ </listitem>
+ </varlistentry>
+
<varlistentry id="sql-createsubscription-params-with-copy-data">
<term><literal>copy_data</literal> (<type>boolean</type>)</term>
<listitem>
--
2.49.0
[application/octet-stream] v25-0002-Implement-the-conflict-insertion-infrastructure-.patch (28.7K, ../../CAFiTN-uqNN9S_hRuda_th5MEpywa15g+XO00yM6tNJ-spGRRJw@mail.gmail.com/3-v25-0002-Implement-the-conflict-insertion-infrastructure-.patch)
download | inline diff:
From f0f8776dda105064b4385cad370a5e516bc87d2a Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 21 Jan 2026 18:35:00 +0530
Subject: [PATCH v25 2/3] Implement the conflict insertion infrastructure for
the conflict log table
This patch introduces the core logic to populate the conflict log table whenever
a logical replication conflict is detected. It captures the remote transaction
details along with the corresponding local state at the time of the conflict.
Handling Multi-row Conflicts: A single remote tuple may conflict with multiple
local tuples (e.g., in the case of multiple_unique_conflicts). To handle this,
the infrastructure creates a single row in the conflict log table for each
remote tuple. The details of all conflicting local rows are aggregated into a
single JSON array in the local_conflicts column.
The JSON array uses the following structured format:
[ { "xid": "1001", "commit_ts": "2025-12-25 10:00:00+05:30", "origin": "node_1",
"key": {"id": 1}, "tuple": {"id": 1, "val": "old_data"} }, ... ]
Example of querying the structured conflict data:
SELECT remote_xid, relname, remote_origin, local_conflicts[1] ->> 'xid' AS local_xid,
local_conflicts[1] ->> 'tuple' AS local_tuple
FROM myschema.conflict_log_history2;
remote_xid | relname | remote_origin | local_xid | local_tuple
------------+----------+---------------+-----------+---------------------
760 | test | pg_16406 | 771 | {"a":1,"b":10}
765 | conf_tab | pg_16406 | 775 | {"a":2,"b":2,"c":2}
---
src/backend/replication/logical/conflict.c | 561 +++++++++++++++++++--
src/backend/replication/logical/launcher.c | 1 +
src/backend/replication/logical/worker.c | 31 +-
src/include/replication/conflict.h | 3 +
src/include/replication/worker_internal.h | 7 +
src/test/subscription/t/035_conflicts.pl | 47 +-
6 files changed, 605 insertions(+), 45 deletions(-)
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 722514149fe..61f4d65f7da 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -15,13 +15,21 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/tableam.h"
+#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "pgstat.h"
#include "replication/conflict.h"
#include "replication/worker_internal.h"
#include "storage/lmgr.h"
+#include "utils/array.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
#include "utils/lsyscache.h"
+#include "utils/pg_lsn.h"
+#include "utils/json.h"
static const char *const ConflictTypeNames[] = {
[CT_INSERT_EXISTS] = "insert_exists",
@@ -34,6 +42,18 @@ static const char *const ConflictTypeNames[] = {
[CT_MULTIPLE_UNIQUE_CONFLICTS] = "multiple_unique_conflicts"
};
+/* Schema for the elements within the 'local_conflicts' JSON array */
+static const ConflictLogColumnDef LocalConflictSchema[] =
+{
+ { .attname = "xid", .atttypid = XIDOID },
+ { .attname = "commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "origin", .atttypid = TEXTOID },
+ { .attname = "key", .atttypid = JSONOID },
+ { .attname = "tuple", .atttypid = JSONOID }
+};
+
+#define MAX_LOCAL_CONFLICT_INFO_ATTRS lengthof(LocalConflictSchema)
+
static int errcode_apply_conflict(ConflictType type);
static void errdetail_apply_conflict(EState *estate,
ResultRelInfo *relinfo,
@@ -50,8 +70,27 @@ static void get_tuple_desc(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *localslot, char **local_desc,
TupleTableSlot *remoteslot, char **remote_desc,
Oid indexoid);
+static void build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull);
static char *build_index_value_desc(EState *estate, Relation localrel,
TupleTableSlot *slot, Oid indexoid);
+static Datum tuple_table_slot_to_json_datum(TupleTableSlot *slot);
+static Datum tuple_table_slot_to_indextup_json(EState *estate,
+ Relation localrel,
+ Oid replica_index,
+ TupleTableSlot *slot);
+static TupleDesc build_conflict_tupledesc(void);
+static Datum build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples);
+static void prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot);
/*
* Get the xmin and commit timestamp data (origin and timestamp) associated
@@ -105,30 +144,90 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
ConflictType type, TupleTableSlot *searchslot,
TupleTableSlot *remoteslot, List *conflicttuples)
{
- Relation localrel = relinfo->ri_RelationDesc;
- StringInfoData err_detail;
+ Relation localrel = relinfo->ri_RelationDesc;
+ ConflictLogDest dest;
+ Relation conflictlogrel;
+ bool log_dest_clt;
+ bool log_dest_logfile;
- initStringInfo(&err_detail);
+ pgstat_report_subscription_conflict(MySubscription->oid, type);
- /* Form errdetail message by combining conflicting tuples information. */
- foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
- errdetail_apply_conflict(estate, relinfo, type, searchslot,
- conflicttuple->slot, remoteslot,
- conflicttuple->indexoid,
- conflicttuple->xmin,
- conflicttuple->origin,
- conflicttuple->ts,
- &err_detail);
+ /*
+ * Get the conflict log destination. Also, (if there is one) return the
+ * CLT relation already opened and ready for insertion.
+ */
+ conflictlogrel = GetConflictLogDestAndTable(&dest);
- pgstat_report_subscription_conflict(MySubscription->oid, type);
+ log_dest_clt = ((dest & CONFLICT_LOG_DEST_TABLE) != 0);
+ log_dest_logfile = ((dest & CONFLICT_LOG_DEST_LOG) != 0);
- ereport(elevel,
- errcode_apply_conflict(type),
- errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
- get_namespace_name(RelationGetNamespace(localrel)),
- RelationGetRelationName(localrel),
- ConflictTypeNames[type]),
- errdetail_internal("%s", err_detail.data));
+ /* Insert to table if requested. */
+ if (log_dest_clt)
+ {
+ Assert(conflictlogrel != NULL);
+
+ /*
+ * Prepare the conflict log tuple. If the error level is below ERROR,
+ * insert it immediately. Otherwise, defer the insertion to a new
+ * transaction after the current one aborts, ensuring the insertion of
+ * the log tuple is not rolled back.
+ */
+ prepare_conflict_log_tuple(estate,
+ relinfo->ri_RelationDesc,
+ conflictlogrel,
+ type,
+ searchslot,
+ conflicttuples,
+ remoteslot);
+ if (elevel < ERROR)
+ InsertConflictLogTuple(conflictlogrel);
+
+ if (!log_dest_logfile)
+ {
+ /*
+ * Not logging conflict details to the server log; Report the error
+ * msg but omit raw tuple data from server logs since it's already
+ * captured in the conflict log table.
+ */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail("Conflict details are logged to the conflict log table: %s",
+ RelationGetRelationName(conflictlogrel)));
+ }
+
+ table_close(conflictlogrel, RowExclusiveLock);
+ }
+
+ /* Log into the server log if requested. */
+ if (log_dest_logfile)
+ {
+ StringInfoData err_detail;
+
+ initStringInfo(&err_detail);
+
+ /* Form errdetail message by combining conflicting tuples information. */
+ foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
+ errdetail_apply_conflict(estate, relinfo, type, searchslot,
+ conflicttuple->slot, remoteslot,
+ conflicttuple->indexoid,
+ conflicttuple->xmin,
+ conflicttuple->origin,
+ conflicttuple->ts,
+ &err_detail);
+
+ /* Standard reporting with full internal details. */
+ ereport(elevel,
+ errcode_apply_conflict(type),
+ errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+ get_namespace_name(RelationGetNamespace(localrel)),
+ RelationGetRelationName(localrel),
+ ConflictTypeNames[type]),
+ errdetail_internal("%s", err_detail.data));
+ }
}
/*
@@ -162,6 +261,64 @@ InitConflictIndexes(ResultRelInfo *relInfo)
relInfo->ri_onConflictArbiterIndexes = uniqueIndexes;
}
+/*
+ * GetConflictLogDestAndTable
+ *
+ * Fetches conflict logging metadata from the cached MySubscription pointer.
+ * Sets the destination enum in *log_dest and, if applicable, opens and
+ * returns the relation handle for the conflict log table.
+ */
+Relation
+GetConflictLogDestAndTable(ConflictLogDest *log_dest)
+{
+ Oid conflictlogrelid;
+ Relation conflictlogrel = NULL;
+
+ /*
+ * Convert the text log destination to the internal enum. MySubscription
+ * already contains the data from pg_subscription.
+ */
+ *log_dest = GetLogDestination(MySubscription->conflictlogdest);
+
+ /* Quick exit if a conflict log table was not requested. */
+ if ((*log_dest & CONFLICT_LOG_DEST_TABLE) == 0)
+ return NULL;
+
+ conflictlogrelid = MySubscription->conflictlogrelid;
+
+ Assert(OidIsValid(conflictlogrelid));
+
+ conflictlogrel = table_open(conflictlogrelid, RowExclusiveLock);
+ if (conflictlogrel == NULL)
+ elog(ERROR, "could not open conflict log table (OID %u)",
+ conflictlogrelid);
+
+ return conflictlogrel;
+}
+
+/*
+ * InsertConflictLogTuple
+ *
+ * Insert conflict log tuple into the conflict log table. It uses
+ * HEAP_INSERT_NO_LOGICAL to explicitly block logical decoding of the tuple
+ * inserted into the conflict log table.
+ */
+void
+InsertConflictLogTuple(Relation conflictlogrel)
+{
+ int options = HEAP_INSERT_NO_LOGICAL;
+
+ /* A valid tuple must be prepared and stored in MyLogicalRepWorker. */
+ Assert(MyLogicalRepWorker->conflict_log_tuple != NULL);
+
+ heap_insert(conflictlogrel, MyLogicalRepWorker->conflict_log_tuple,
+ GetCurrentCommandId(true), options, NULL);
+
+ /* Free conflict log tuple. */
+ heap_freetuple(MyLogicalRepWorker->conflict_log_tuple);
+ MyLogicalRepWorker->conflict_log_tuple = NULL;
+}
+
/*
* Add SQLSTATE error code to the current conflict report.
*/
@@ -522,6 +679,40 @@ get_tuple_desc(EState *estate, ResultRelInfo *relinfo, ConflictType type,
}
}
+/*
+ * Helper function to extract the "raw" index key Datums and their null flags
+ * from a TupleTableSlot, given an already open index descriptor.
+ * This is the reusable core logic.
+ */
+static void
+build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull)
+{
+ TupleTableSlot *tableslot = slot;
+
+ /*
+ * If the slot is a virtual slot, copy it into a heap tuple slot as
+ * FormIndexDatum only works with heap tuple slots.
+ */
+ if (TTS_IS_VIRTUAL(slot))
+ {
+ /* Slot is created within the EState's tuple table */
+ tableslot = table_slot_create(localrel, &estate->es_tupleTable);
+ tableslot = ExecCopySlot(tableslot, slot);
+ }
+
+ /*
+ * Initialize ecxt_scantuple for potential use in FormIndexDatum
+ */
+ GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+
+ /* Form the index datums */
+ FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values,
+ isnull);
+}
+
/*
* Helper functions to construct a string describing the contents of an index
* entry. See BuildIndexValueDescription for details.
@@ -537,41 +728,325 @@ build_index_value_desc(EState *estate, Relation localrel, TupleTableSlot *slot,
Relation indexDesc;
Datum values[INDEX_MAX_KEYS];
bool isnull[INDEX_MAX_KEYS];
- TupleTableSlot *tableslot = slot;
- if (!tableslot)
+ if (!slot)
return NULL;
Assert(CheckRelationOidLockedByMe(indexoid, RowExclusiveLock, true));
indexDesc = index_open(indexoid, NoLock);
- /*
- * If the slot is a virtual slot, copy it into a heap tuple slot as
- * FormIndexDatum only works with heap tuple slots.
- */
- if (TTS_IS_VIRTUAL(slot))
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+
+ index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+
+ index_close(indexDesc, NoLock);
+
+ return index_value;
+}
+
+/*
+ * tuple_table_slot_to_json_datum
+ *
+ * Helper function to convert a TupleTableSlot to JSON.
+ */
+static Datum
+tuple_table_slot_to_json_datum(TupleTableSlot *slot)
+{
+ HeapTuple tuple;
+ Datum datum;
+ Datum json;
+
+ Assert(slot != NULL);
+
+ tuple = ExecCopySlotHeapTuple(slot);
+ datum = heap_copy_tuple_as_datum(tuple, slot->tts_tupleDescriptor);
+
+ json = DirectFunctionCall1(row_to_json, datum);
+ heap_freetuple(tuple);
+
+ return json;
+}
+
+/*
+ * tuple_table_slot_to_indextup_json
+ *
+ * Fetch replica identity key from the tuple table slot and convert into a
+ * JSON datum.
+ */
+static Datum
+tuple_table_slot_to_indextup_json(EState *estate, Relation localrel,
+ Oid indexid, TupleTableSlot *slot)
+{
+ Relation indexDesc;
+ Datum values[INDEX_MAX_KEYS];
+ bool isnull[INDEX_MAX_KEYS];
+ HeapTuple tuple;
+ TupleDesc tupdesc;
+ Datum datum;
+
+ Assert(slot != NULL);
+
+ Assert(CheckRelationOidLockedByMe(indexid, RowExclusiveLock, true));
+
+ indexDesc = index_open(indexid, NoLock);
+
+ build_index_datums_from_slot(estate, localrel, slot, indexDesc, values,
+ isnull);
+ tupdesc = RelationGetDescr(indexDesc);
+
+ /* Bless the tupdesc so it can be looked up by row_to_json. */
+ BlessTupleDesc(tupdesc);
+
+ /* Form the replica identity tuple. */
+ tuple = heap_form_tuple(tupdesc, values, isnull);
+ datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ index_close(indexDesc, NoLock);
+ heap_freetuple(tuple);
+
+ /* Convert to a JSON datum. */
+ return DirectFunctionCall1(row_to_json, datum);
+}
+
+/*
+ * build_conflict_tupledesc
+ *
+ * Build and bless a tuple descriptor for the internal conflict log table
+ * based on the predefined LocalConflictSchema.
+ */
+static TupleDesc
+build_conflict_tupledesc(void)
+{
+ TupleDesc tupdesc;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ for (int i = 0; i < MAX_LOCAL_CONFLICT_INFO_ATTRS; i++)
+ TupleDescInitEntry(tupdesc, (AttrNumber) (i + 1),
+ LocalConflictSchema[i].attname,
+ LocalConflictSchema[i].atttypid,
+ -1, 0);
+
+ BlessTupleDesc(tupdesc);
+
+ return tupdesc;
+}
+
+/*
+ * Builds the local conflicts JSON array column from the list of
+ * ConflictTupleInfo objects.
+ *
+ * Example output structure:
+ * [ { "xid": "1001", "commit_ts": "...", "origin": "...", "tuple": {...} }, ... ]
+ */
+static Datum
+build_local_conflicts_json_array(EState *estate, Relation rel,
+ ConflictType conflict_type,
+ List *conflicttuples)
+{
+ ListCell *lc;
+ List *json_datums = NIL;
+ Datum *json_datum_array;
+ bool *json_null_array;
+ Datum json_array_datum;
+ int num_conflicts;
+ int i;
+ int16 typlen;
+ bool typbyval;
+ char typalign;
+ TupleDesc tupdesc;
+
+ /* Build local conflicts tuple descriptor. */
+ tupdesc = build_conflict_tupledesc();
+
+ /* Process local conflict tuple list and prepare an array of JSON. */
+ foreach_ptr(ConflictTupleInfo, conflicttuple, conflicttuples)
{
- tableslot = table_slot_create(localrel, &estate->es_tupleTable);
- tableslot = ExecCopySlot(tableslot, slot);
+ Datum values[MAX_LOCAL_CONFLICT_INFO_ATTRS] = {0};
+ bool nulls[MAX_LOCAL_CONFLICT_INFO_ATTRS] = {0};
+ char *origin_name = NULL;
+ HeapTuple tuple;
+ Datum json_datum;
+ int attno;
+
+ attno = 0;
+ values[attno++] = TransactionIdGetDatum(conflicttuple->xmin);
+
+ if (conflicttuple->ts)
+ values[attno++] = TimestampTzGetDatum(conflicttuple->ts);
+ else
+ nulls[attno++] = true;
+
+ if (conflicttuple->origin != InvalidRepOriginId)
+ replorigin_by_oid(conflicttuple->origin, true, &origin_name);
+
+ /* Store empty string if origin name for the tuple is NULL. */
+ if (origin_name != NULL)
+ values[attno++] = CStringGetTextDatum(origin_name);
+ else
+ nulls[attno++] = true;
+
+ /*
+ * Add the conflicting key values in the case of a unique constraint
+ * violation.
+ */
+ if (conflict_type == CT_INSERT_EXISTS ||
+ conflict_type == CT_UPDATE_EXISTS ||
+ conflict_type == CT_MULTIPLE_UNIQUE_CONFLICTS)
+ {
+ Oid indexoid = conflicttuple->indexoid;
+
+ Assert(OidIsValid(indexoid) && conflicttuple->slot &&
+ CheckRelationOidLockedByMe(indexoid, RowExclusiveLock,
+ true));
+ values[attno++] =
+ tuple_table_slot_to_indextup_json(estate, rel,
+ indexoid,
+ conflicttuple->slot);
+ }
+ else
+ nulls[attno++] = true;
+
+ /* Convert conflicting tuple to JSON datum. */
+ if (conflicttuple->slot)
+ values[attno] = tuple_table_slot_to_json_datum(conflicttuple->slot);
+ else
+ nulls[attno] = true;
+
+ Assert(attno + 1 == MAX_LOCAL_CONFLICT_INFO_ATTRS);
+
+ tuple = heap_form_tuple(tupdesc, values, nulls);
+
+ json_datum = heap_copy_tuple_as_datum(tuple, tupdesc);
+
+ /*
+ * Build the higher level JSON datum in format described in function
+ * header.
+ */
+ json_datum = DirectFunctionCall1(row_to_json, json_datum);
+
+ /* Done with the temporary tuple. */
+ heap_freetuple(tuple);
+
+ /* Add to the array element. */
+ json_datums = lappend(json_datums, (void *) json_datum);
}
- /*
- * Initialize ecxt_scantuple for potential use in FormIndexDatum when
- * index expressions are present.
- */
- GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
+ num_conflicts = list_length(json_datums);
- /*
- * The values/nulls arrays passed to BuildIndexValueDescription should be
- * the results of FormIndexDatum, which are the "raw" input to the index
- * AM.
- */
- FormIndexDatum(BuildIndexInfo(indexDesc), tableslot, estate, values, isnull);
+ json_datum_array = palloc_array(Datum, num_conflicts);
+ json_null_array = palloc0_array(bool, num_conflicts);
- index_value = BuildIndexValueDescription(indexDesc, values, isnull);
+ i = 0;
+ foreach(lc, json_datums)
+ {
+ json_datum_array[i] = (Datum) lfirst(lc);
+ i++;
+ }
- index_close(indexDesc, NoLock);
+ /* Construct the JSON array Datum. */
+ get_typlenbyvalalign(JSONOID, &typlen, &typbyval, &typalign);
+ json_array_datum = PointerGetDatum(construct_array(json_datum_array,
+ num_conflicts,
+ JSONOID,
+ typlen,
+ typbyval,
+ typalign));
+ pfree(json_datum_array);
+ pfree(json_null_array);
+
+ return json_array_datum;
+}
- return index_value;
+/*
+ * prepare_conflict_log_tuple
+ *
+ * This routine prepares a tuple detailing a conflict encountered during
+ * logical replication. The prepared tuple will be stored in
+ * MyLogicalRepWorker->conflict_log_tuple which should be inserted into the
+ * conflict log table by calling InsertConflictLogTuple.
+ */
+static void
+prepare_conflict_log_tuple(EState *estate, Relation rel,
+ Relation conflictlogrel,
+ ConflictType conflict_type,
+ TupleTableSlot *searchslot,
+ List *conflicttuples,
+ TupleTableSlot *remoteslot)
+{
+ Datum values[MAX_CONFLICT_ATTR_NUM] = {0};
+ bool nulls[MAX_CONFLICT_ATTR_NUM] = {0};
+ int attno;
+ char *remote_origin = NULL;
+ MemoryContext oldctx;
+
+ Assert(MyLogicalRepWorker->conflict_log_tuple == NULL);
+
+ /* Populate the values and nulls arrays. */
+ attno = 0;
+ values[attno++] = ObjectIdGetDatum(RelationGetRelid(rel));
+
+ values[attno++] =
+ CStringGetTextDatum(get_namespace_name(RelationGetNamespace(rel)));
+
+ values[attno++] = CStringGetTextDatum(RelationGetRelationName(rel));
+
+ values[attno++] = CStringGetTextDatum(ConflictTypeNames[conflict_type]);
+
+ if (TransactionIdIsValid(remote_xid))
+ values[attno++] = TransactionIdGetDatum(remote_xid);
+ else
+ nulls[attno++] = true;
+
+ values[attno++] = LSNGetDatum(remote_final_lsn);
+
+ if (remote_commit_ts > 0)
+ values[attno++] = TimestampTzGetDatum(remote_commit_ts);
+ else
+ nulls[attno++] = true;
+
+ if (replorigin_session_origin != InvalidRepOriginId)
+ replorigin_by_oid(replorigin_session_origin, true, &remote_origin);
+
+ if (remote_origin != NULL)
+ values[attno++] = CStringGetTextDatum(remote_origin);
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(searchslot))
+ {
+ Oid replica_index = GetRelationIdentityOrPK(rel);
+
+ /*
+ * If the table has a valid replica identity index, build the index
+ * JSON datum from key value. Otherwise, construct it from the complete
+ * tuple in REPLICA IDENTITY FULL cases.
+ */
+ if (OidIsValid(replica_index))
+ values[attno++] = tuple_table_slot_to_indextup_json(estate, rel,
+ replica_index,
+ searchslot);
+ else
+ values[attno++] = tuple_table_slot_to_json_datum(searchslot);
+ }
+ else
+ nulls[attno++] = true;
+
+ if (!TupIsNull(remoteslot))
+ values[attno++] = tuple_table_slot_to_json_datum(remoteslot);
+ else
+ nulls[attno++] = true;
+
+ values[attno] = build_local_conflicts_json_array(estate, rel,
+ conflict_type,
+ conflicttuples);
+
+ Assert(attno + 1 == MAX_CONFLICT_ATTR_NUM);
+
+ oldctx = MemoryContextSwitchTo(ApplyContext);
+ MyLogicalRepWorker->conflict_log_tuple =
+ heap_form_tuple(RelationGetDescr(conflictlogrel), values, nulls);
+ MemoryContextSwitchTo(oldctx);
}
diff --git a/src/backend/replication/logical/launcher.c b/src/backend/replication/logical/launcher.c
index 3ed86480be2..2dda5a44218 100644
--- a/src/backend/replication/logical/launcher.c
+++ b/src/backend/replication/logical/launcher.c
@@ -477,6 +477,7 @@ retry:
worker->oldest_nonremovable_xid = retain_dead_tuples
? MyReplicationSlot->data.xmin
: InvalidTransactionId;
+ worker->conflict_log_tuple = NULL;
worker->last_lsn = InvalidXLogRecPtr;
TIMESTAMP_NOBEGIN(worker->last_send_time);
TIMESTAMP_NOBEGIN(worker->last_recv_time);
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index ad281e7069b..d4be1122603 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -482,7 +482,9 @@ static bool MySubscriptionValid = false;
static List *on_commit_wakeup_workers_subids = NIL;
bool in_remote_transaction = false;
-static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+TransactionId remote_xid = InvalidTransactionId;
+TimestampTz remote_commit_ts = 0;
/* fields valid only when processing streamed transaction */
static bool in_streamed_transaction = false;
@@ -1219,6 +1221,8 @@ apply_handle_begin(StringInfo s)
set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
remote_final_lsn = begin_data.final_lsn;
+ remote_commit_ts = begin_data.committime;
+ remote_xid = begin_data.xid;
maybe_start_skipping_changes(begin_data.final_lsn);
@@ -1745,6 +1749,10 @@ apply_handle_stream_start(StringInfo s)
/* extract XID of the top-level transaction */
stream_xid = logicalrep_read_stream_start(s, &first_segment);
+ remote_xid = stream_xid;
+ remote_final_lsn = InvalidXLogRecPtr;
+ remote_commit_ts = 0;
+
if (!TransactionIdIsValid(stream_xid))
ereport(ERROR,
(errcode(ERRCODE_PROTOCOL_VIOLATION),
@@ -5609,6 +5617,27 @@ start_apply(XLogRecPtr origin_startpos)
pgstat_report_subscription_error(MySubscription->oid,
MyLogicalRepWorker->type);
+ /*
+ * Insert any pending conflict log tuple under a new transaction.
+ */
+ if (MyLogicalRepWorker->conflict_log_tuple != NULL)
+ {
+ Relation conflictlogrel;
+ ConflictLogDest dest;
+
+ StartTransactionCommand();
+ PushActiveSnapshot(GetTransactionSnapshot());
+
+ /* Open conflict log table and insert the tuple. */
+ conflictlogrel = GetConflictLogDestAndTable(&dest);
+ Assert((dest & CONFLICT_LOG_DEST_TABLE) != 0);
+ InsertConflictLogTuple(conflictlogrel);
+ table_close(conflictlogrel, RowExclusiveLock);
+
+ PopActiveSnapshot();
+ CommitTransactionCommand();
+ }
+
PG_RE_THROW();
}
}
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index 31facc1ec7e..dbf259b8d01 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -144,4 +144,7 @@ extern void ReportApplyConflict(EState *estate, ResultRelInfo *relinfo,
TupleTableSlot *remoteslot,
List *conflicttuples);
extern void InitConflictIndexes(ResultRelInfo *relInfo);
+extern Relation GetConflictLogDestAndTable(ConflictLogDest *log_dest);
+extern void InsertConflictLogTuple(Relation conflictlogrel);
+extern bool ValidateConflictLogTable(Relation rel);
#endif
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index c1285fdd1bc..5bedfc5450f 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -101,6 +101,9 @@ typedef struct LogicalRepWorker
*/
TransactionId oldest_nonremovable_xid;
+ /* A conflict log tuple that is prepared but not yet inserted. */
+ HeapTuple conflict_log_tuple;
+
/* Stats. */
XLogRecPtr last_lsn;
TimestampTz last_send_time;
@@ -256,6 +259,10 @@ extern PGDLLIMPORT bool InitializingApplyWorker;
extern PGDLLIMPORT List *table_states_not_ready;
+extern XLogRecPtr remote_final_lsn;
+extern TimestampTz remote_commit_ts;
+extern TransactionId remote_xid;
+
extern void logicalrep_worker_attach(int slot);
extern LogicalRepWorker *logicalrep_worker_find(LogicalRepWorkerType wtype,
Oid subid, Oid relid,
diff --git a/src/test/subscription/t/035_conflicts.pl b/src/test/subscription/t/035_conflicts.pl
index 426ad74cf33..32925bd49ca 100644
--- a/src/test/subscription/t/035_conflicts.pl
+++ b/src/test/subscription/t/035_conflicts.pl
@@ -50,7 +50,7 @@ $node_subscriber->safe_psql(
'postgres',
"CREATE SUBSCRIPTION sub_tab
CONNECTION '$publisher_connstr application_name=$appname'
- PUBLICATION pub_tab;");
+ PUBLICATION pub_tab WITH (conflict_log_destination=all)");
# Wait for initial table sync to finish
$node_subscriber->wait_for_subscription_sync($node_publisher, $appname);
@@ -84,10 +84,35 @@ $node_subscriber->wait_for_log(
.*Key already exists in unique index \"conf_tab_c_key\", modified in transaction .*: key \(c\)=\(4\), local row \(4, 4, 4\)./,
$log_offset);
+# Verify the contents of the Conflict Log Table (CLT)
+# This section ensures that the clt contains the expected
+# type and specific key data.
+my $subid = $node_subscriber->safe_psql('postgres',
+ "SELECT oid FROM pg_subscription WHERE subname = 'sub_tab';");
+my $clt = "pg_conflict.pg_conflict_$subid";
+
+# Wait for the conflict to be logged in the CLT
+my $log_check = $node_subscriber->poll_query_until(
+ 'postgres',
+ "SELECT count(*) > 0 FROM $clt;"
+);
+
+my $conflict_check = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) >= 1 FROM $clt WHERE conflict_type = 'multiple_unique_conflicts';");
+is($conflict_check, 't', 'Verified multiple_unique_conflicts logged into conflict log table');
+
+my $json_query = "SELECT local_conflicts FROM $clt;";
+my $raw_json = $node_subscriber->safe_psql('postgres', $json_query);
+
+# Verify that '2' is present inside the JSON structure using a regex
+# This matches the key/value pattern for "a": 2
+like($raw_json, qr/\\"a\\":2/, 'Verified that key 2 exists in the local_conflicts');
+
pass('multiple_unique_conflicts detected during insert');
# Truncate table to get rid of the error
$node_subscriber->safe_psql('postgres', "TRUNCATE conf_tab;");
+$node_subscriber->safe_psql('postgres', "DELETE FROM $clt");
##################################################
# Test multiple_unique_conflicts due to UPDATE
@@ -114,6 +139,26 @@ $node_subscriber->wait_for_log(
.*Key already exists in unique index \"conf_tab_c_key\", modified in transaction .*: key \(c\)=\(8\), local row \(8, 8, 8\)./,
$log_offset);
+# Verify the contents of the Conflict Log Table (CLT)
+# This section ensures that the CLT contains the expected
+# type and specific key data.
+
+# Wait for the conflict to be logged in the CLT
+$log_check = $node_subscriber->poll_query_until(
+ 'postgres',
+ "SELECT count(*) > 0 FROM $clt;"
+);
+
+$conflict_check = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) >= 1 FROM $clt WHERE conflict_type = 'multiple_unique_conflicts';");
+is($conflict_check, 't', 'Verified multiple_unique_conflicts logged into conflict log table');
+
+$raw_json = $node_subscriber->safe_psql('postgres', $json_query);
+
+# Verify that '6' is present inside the JSON structure using a regex
+# This matches the key/value pattern for "a": 6
+like($raw_json, qr/\\"a\\":6/, 'Verified that key 6 exists in the local_conflicts');
+
pass('multiple_unique_conflicts detected during update');
# Truncate table to get rid of the error
--
2.49.0
[application/octet-stream] v25-0001-Add-configurable-conflict-log-table-for-Logical-.patch (112.1K, ../../CAFiTN-uqNN9S_hRuda_th5MEpywa15g+XO00yM6tNJ-spGRRJw@mail.gmail.com/4-v25-0001-Add-configurable-conflict-log-table-for-Logical-.patch)
download | inline diff:
From 624739b6651a32ee5641514c2ef4fffd73653aa0 Mon Sep 17 00:00:00 2001
From: Dilip Kumar <dilipkumarb@google.com>
Date: Wed, 17 Dec 2025 11:53:47 +0530
Subject: [PATCH v25 1/3] Add configurable conflict log table for Logical
Replication
This patch adds a feature to provide a structured, queryable record of all
logical replication conflicts. The current approach of logging conflicts as
plain text in the server logs makes it difficult to query, analyze, and
use for external monitoring and automation.
This patch addresses these limitations by introducing a configurable
conflict_log_destination=('log'/'table'/'all') option in the CREATE SUBSCRIPTION
command.
If the user chooses to enable logging to a table (by selecting 'table' or 'all'),
an internal logging table named conflict_log_table_<subid> is automatically
created within a dedicated, system-managed namespace named pg_conflict. This table
is linked to the subscription via an internal dependency, ensuring it is
automatically dropped when the subscription is removed.
The conflict details, including the original and remote tuples, are stored in JSON
columns, providing a flexible format to accommodate different table schemas.
The log table captures essential attributes such as local and remote transaction IDs,
LSNs, commit timestamps, and conflict type, providing a complete record for post-mortem
analysis.
This feature will make logical replication conflicts easier to monitor and manage,
significantly improving the overall resilience and operability of replication setups.
The conflict log tables will not be included in a publication, even if the publication
is configured to include ALL TABLES or ALL TABLES IN SCHEMA.
---
src/backend/catalog/catalog.c | 27 +-
src/backend/catalog/heap.c | 3 +-
src/backend/catalog/namespace.c | 8 +-
src/backend/catalog/pg_publication.c | 14 +-
src/backend/catalog/pg_subscription.c | 7 +
src/backend/commands/subscriptioncmds.c | 239 ++++++++++-
src/backend/commands/tablecmds.c | 6 +-
src/backend/executor/execMain.c | 26 ++
src/bin/psql/describe.c | 16 +-
src/bin/psql/tab-complete.in.c | 8 +-
src/include/catalog/catalog.h | 2 +
src/include/catalog/pg_namespace.dat | 3 +
src/include/catalog/pg_subscription.h | 11 +
src/include/commands/subscriptioncmds.h | 3 +
src/include/replication/conflict.h | 55 +++
src/test/regress/expected/subscription.out | 444 +++++++++++++++++----
src/test/regress/sql/subscription.sql | 217 ++++++++++
src/tools/pgindent/typedefs.list | 2 +
18 files changed, 989 insertions(+), 102 deletions(-)
diff --git a/src/backend/catalog/catalog.c b/src/backend/catalog/catalog.c
index 7be49032934..d438dc682ec 100644
--- a/src/backend/catalog/catalog.c
+++ b/src/backend/catalog/catalog.c
@@ -86,7 +86,8 @@ bool
IsSystemClass(Oid relid, Form_pg_class reltuple)
{
/* IsCatalogRelationOid is a bit faster, so test that first */
- return (IsCatalogRelationOid(relid) || IsToastClass(reltuple));
+ return (IsCatalogRelationOid(relid) || IsToastClass(reltuple)
+ || IsConflictClass(reltuple));
}
/*
@@ -230,6 +231,18 @@ IsToastClass(Form_pg_class reltuple)
return IsToastNamespace(relnamespace);
}
+/*
+ * IsConflictClass - Check if the given pg_class tuple belongs to the conflict
+ * namespace.
+ */
+bool
+IsConflictClass(Form_pg_class reltuple)
+{
+ Oid relnamespace = reltuple->relnamespace;
+
+ return IsConflictNamespace(relnamespace);
+}
+
/*
* IsCatalogNamespace
* True iff namespace is pg_catalog.
@@ -264,6 +277,18 @@ IsToastNamespace(Oid namespaceId)
isTempToastNamespace(namespaceId);
}
+/*
+ * IsConflictNamespace
+ * True iff namespace is pg_conflict.
+ *
+ * Does not perform any catalog accesses.
+ */
+bool
+IsConflictNamespace(Oid namespaceId)
+{
+ return namespaceId == PG_CONFLICT_NAMESPACE;
+}
+
/*
* IsReservedName
diff --git a/src/backend/catalog/heap.c b/src/backend/catalog/heap.c
index 606434823cf..10dadf378a4 100644
--- a/src/backend/catalog/heap.c
+++ b/src/backend/catalog/heap.c
@@ -314,7 +314,8 @@ heap_create(const char *relname,
*/
if (!allow_system_table_mods &&
((IsCatalogNamespace(relnamespace) && relkind != RELKIND_INDEX) ||
- IsToastNamespace(relnamespace)) &&
+ IsToastNamespace(relnamespace) ||
+ IsConflictNamespace(relnamespace)) &&
IsNormalProcessingMode())
ereport(ERROR,
(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
diff --git a/src/backend/catalog/namespace.c b/src/backend/catalog/namespace.c
index c3b79a2ba48..400292fd06b 100644
--- a/src/backend/catalog/namespace.c
+++ b/src/backend/catalog/namespace.c
@@ -3523,7 +3523,7 @@ LookupCreationNamespace(const char *nspname)
*
* We complain if either the old or new namespaces is a temporary schema
* (or temporary toast schema), or if either the old or new namespaces is the
- * TOAST schema.
+ * TOAST schema or the CONFLICT schema.
*/
void
CheckSetNamespace(Oid oldNspOid, Oid nspOid)
@@ -3539,6 +3539,12 @@ CheckSetNamespace(Oid oldNspOid, Oid nspOid)
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("cannot move objects into or out of TOAST schema")));
+
+ /* similarly for CONFLICT schema */
+ if (nspOid == PG_CONFLICT_NAMESPACE || oldNspOid == PG_CONFLICT_NAMESPACE)
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot move objects into or out of CONFLICT schema")));
}
/*
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index 9a4791c573e..a33c33efe0d 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -31,6 +31,7 @@
#include "catalog/pg_publication_rel.h"
#include "catalog/pg_type.h"
#include "commands/publicationcmds.h"
+#include "commands/subscriptioncmds.h"
#include "funcapi.h"
#include "utils/array.h"
#include "utils/builtins.h"
@@ -85,6 +86,15 @@ check_publication_add_relation(Relation targetrel)
errmsg("cannot add relation \"%s\" to publication",
RelationGetRelationName(targetrel)),
errdetail("This operation is not supported for unlogged tables.")));
+
+ /* Can't be conflict log table */
+ if (IsConflictNamespace(RelationGetNamespace(targetrel)))
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot add relation \"%s.%s\" to publication",
+ get_namespace_name(RelationGetNamespace(targetrel)),
+ RelationGetRelationName(targetrel)),
+ errdetail("This operation is not supported for conflict log tables.")));
}
/*
@@ -95,7 +105,8 @@ static void
check_publication_add_schema(Oid schemaid)
{
/* Can't be system namespace */
- if (IsCatalogNamespace(schemaid) || IsToastNamespace(schemaid))
+ if (IsCatalogNamespace(schemaid) || IsToastNamespace(schemaid) ||
+ IsConflictNamespace(schemaid))
ereport(ERROR,
(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
errmsg("cannot add schema \"%s\" to publication",
@@ -139,6 +150,7 @@ is_publishable_class(Oid relid, Form_pg_class reltuple)
reltuple->relkind == RELKIND_PARTITIONED_TABLE ||
reltuple->relkind == RELKIND_SEQUENCE) &&
!IsCatalogRelationOid(relid) &&
+ !IsConflictClass(reltuple) &&
reltuple->relpersistence == RELPERSISTENCE_PERMANENT &&
relid >= FirstNormalObjectId;
}
diff --git a/src/backend/catalog/pg_subscription.c b/src/backend/catalog/pg_subscription.c
index 2b103245290..285a598497d 100644
--- a/src/backend/catalog/pg_subscription.c
+++ b/src/backend/catalog/pg_subscription.c
@@ -106,6 +106,7 @@ GetSubscription(Oid subid, bool missing_ok)
sub->retaindeadtuples = subform->subretaindeadtuples;
sub->maxretention = subform->submaxretention;
sub->retentionactive = subform->subretentionactive;
+ sub->conflictlogrelid = subform->subconflictlogrelid;
/* Get conninfo */
datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
@@ -141,6 +142,12 @@ GetSubscription(Oid subid, bool missing_ok)
Anum_pg_subscription_suborigin);
sub->origin = TextDatumGetCString(datum);
+ /* Get conflict log destination */
+ datum = SysCacheGetAttrNotNull(SUBSCRIPTIONOID,
+ tup,
+ Anum_pg_subscription_subconflictlogdest);
+ sub->conflictlogdest = TextDatumGetCString(datum);
+
/* Is the subscription owner a superuser? */
sub->ownersuperuser = superuser_arg(sub->owner);
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index d6674f20fc2..de1977f81ef 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -15,25 +15,31 @@
#include "postgres.h"
#include "access/commit_ts.h"
+#include "access/heapam.h"
#include "access/htup_details.h"
#include "access/table.h"
#include "access/twophase.h"
#include "access/xact.h"
#include "catalog/catalog.h"
#include "catalog/dependency.h"
+#include "catalog/heap.h"
#include "catalog/indexing.h"
#include "catalog/namespace.h"
#include "catalog/objectaccess.h"
#include "catalog/objectaddress.h"
+#include "catalog/pg_am_d.h"
#include "catalog/pg_authid_d.h"
#include "catalog/pg_database_d.h"
+#include "catalog/pg_namespace.h"
#include "catalog/pg_subscription.h"
#include "catalog/pg_subscription_rel.h"
#include "catalog/pg_type.h"
+#include "commands/comment.h"
#include "commands/defrem.h"
#include "commands/event_trigger.h"
#include "commands/subscriptioncmds.h"
#include "executor/executor.h"
+#include "funcapi.h"
#include "miscadmin.h"
#include "nodes/makefuncs.h"
#include "pgstat.h"
@@ -51,6 +57,7 @@
#include "utils/lsyscache.h"
#include "utils/memutils.h"
#include "utils/pg_lsn.h"
+#include "utils/regproc.h"
#include "utils/syscache.h"
/*
@@ -75,6 +82,7 @@
#define SUBOPT_MAX_RETENTION_DURATION 0x00008000
#define SUBOPT_LSN 0x00010000
#define SUBOPT_ORIGIN 0x00020000
+#define SUBOPT_CONFLICT_LOG_DEST 0x00040000
/* check if the 'val' has 'bits' set */
#define IsSet(val, bits) (((val) & (bits)) == (bits))
@@ -103,6 +111,7 @@ typedef struct SubOpts
bool retaindeadtuples;
int32 maxretention;
char *origin;
+ ConflictLogDest conflictlogdest;
XLogRecPtr lsn;
} SubOpts;
@@ -135,7 +144,7 @@ static List *merge_publications(List *oldpublist, List *newpublist, bool addpub,
static void ReportSlotConnectionError(List *rstates, Oid subid, char *slotname, char *err);
static void CheckAlterSubOption(Subscription *sub, const char *option,
bool slot_needs_update, bool isTopLevel);
-
+static Oid create_conflict_log_table(Oid subid, char *subname);
/*
* Common option parsing function for CREATE and ALTER SUBSCRIPTION commands.
@@ -191,6 +200,8 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->maxretention = 0;
if (IsSet(supported_opts, SUBOPT_ORIGIN))
opts->origin = pstrdup(LOGICALREP_ORIGIN_ANY);
+ if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DEST))
+ opts->conflictlogdest = CONFLICT_LOG_DEST_LOG;
/* Parse options */
foreach(lc, stmt_options)
@@ -402,6 +413,18 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->specified_opts |= SUBOPT_LSN;
opts->lsn = lsn;
}
+ else if (IsSet(supported_opts, SUBOPT_CONFLICT_LOG_DEST) &&
+ strcmp(defel->defname, "conflict_log_destination") == 0)
+ {
+ char *val;
+
+ if (IsSet(opts->specified_opts, SUBOPT_CONFLICT_LOG_DEST))
+ errorConflictingDefElem(defel, pstate);
+
+ val = defGetString(defel);
+ opts->conflictlogdest = GetLogDestination(val);
+ opts->specified_opts |= SUBOPT_CONFLICT_LOG_DEST;
+ }
else
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
@@ -599,6 +622,7 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bits32 supported_opts;
SubOpts opts = {0};
AclResult aclresult;
+ Oid logrelid = InvalidOid;
/*
* Parse and check options.
@@ -612,7 +636,8 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
SUBOPT_DISABLE_ON_ERR | SUBOPT_PASSWORD_REQUIRED |
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
- SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN);
+ SUBOPT_MAX_RETENTION_DURATION | SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_DEST);
parse_subscription_options(pstate, stmt->options, supported_opts, &opts);
/*
@@ -747,6 +772,18 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
values[Anum_pg_subscription_suborigin - 1] =
CStringGetTextDatum(opts.origin);
+ /* Always set the destination, default will be 'log'. */
+ values[Anum_pg_subscription_subconflictlogdest - 1] =
+ CStringGetTextDatum(ConflictLogDestNames[opts.conflictlogdest]);
+
+ /* If logging to a table is required, physically create the table. */
+ if (IsSet(opts.conflictlogdest, CONFLICT_LOG_DEST_TABLE))
+ logrelid = create_conflict_log_table(subid, stmt->subname);
+
+ /* Store table OID in the catalog. */
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(logrelid);
+
tup = heap_form_tuple(RelationGetDescr(rel), values, nulls);
/* Insert tuple into catalog. */
@@ -1410,7 +1447,8 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
SUBOPT_RUN_AS_OWNER | SUBOPT_FAILOVER |
SUBOPT_RETAIN_DEAD_TUPLES |
SUBOPT_MAX_RETENTION_DURATION |
- SUBOPT_ORIGIN);
+ SUBOPT_ORIGIN |
+ SUBOPT_CONFLICT_LOG_DEST);
parse_subscription_options(pstate, stmt->options,
supported_opts, &opts);
@@ -1665,6 +1703,63 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
origin = opts.origin;
}
+ if (IsSet(opts.specified_opts, SUBOPT_CONFLICT_LOG_DEST))
+ {
+ ConflictLogDest old_dest =
+ GetLogDestination(sub->conflictlogdest);
+
+ if (opts.conflictlogdest != old_dest)
+ {
+ bool want_table = IsSet(opts.conflictlogdest,
+ CONFLICT_LOG_DEST_TABLE);
+ bool has_oldtable =
+ IsSet(old_dest, CONFLICT_LOG_DEST_TABLE);
+
+ values[Anum_pg_subscription_subconflictlogdest - 1] =
+ CStringGetTextDatum(ConflictLogDestNames[opts.conflictlogdest]);
+ replaces[Anum_pg_subscription_subconflictlogdest - 1] = true;
+
+ if (want_table && !has_oldtable)
+ {
+ Oid relid;
+
+ relid = create_conflict_log_table(subid, sub->name);
+
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(relid);
+ replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
+ true;
+ }
+ else if (!want_table && has_oldtable)
+ {
+ ObjectAddress object;
+
+ /*
+ * Conflict log tables are recorded as internal
+ * dependencies of the subscription. Drop the
+ * table if it is not required anymore to avoid
+ * stale or orphaned relations.
+ *
+ * XXX: At present, only conflict log tables are
+ * managed this way. In future if we introduce
+ * additional internal dependencies, we may need
+ * a targeted deletion to avoid deletion of any
+ * other objects.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId,
+ subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
+ values[Anum_pg_subscription_subconflictlogrelid - 1] =
+ ObjectIdGetDatum(InvalidOid);
+ replaces[Anum_pg_subscription_subconflictlogrelid - 1] =
+ true;
+ }
+ }
+ }
+
update_tuple = true;
break;
}
@@ -2027,6 +2122,7 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
Form_pg_subscription form;
List *rstates;
bool must_use_password;
+ ObjectAddress object;
/*
* The launcher may concurrently start a new worker for this subscription.
@@ -2184,6 +2280,19 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
/* Clean up dependencies */
deleteSharedDependencyRecordsFor(SubscriptionRelationId, subid, 0);
+ /*
+ * Conflict log tables are recorded as internal dependencies of the
+ * subscription. We must drop the dependent objects before the
+ * subscription itself is removed. By using
+ * PERFORM_DELETION_SKIP_ORIGINAL, we ensure that only the conflict log
+ * table is reaped while the subscription remains for the final deletion
+ * step.
+ */
+ ObjectAddressSet(object, SubscriptionRelationId, subid);
+ performDeletion(&object, DROP_CASCADE,
+ PERFORM_DELETION_INTERNAL |
+ PERFORM_DELETION_SKIP_ORIGINAL);
+
/* Remove any associated relation synchronization states. */
RemoveSubscriptionRel(subid, InvalidOid);
@@ -3190,3 +3299,127 @@ defGetStreamingMode(DefElem *def)
def->defname)));
return LOGICALREP_STREAM_OFF; /* keep compiler quiet */
}
+
+/*
+ * Builds the TupleDesc for the conflict log table.
+ */
+static TupleDesc
+create_conflict_log_table_tupdesc(void)
+{
+ TupleDesc tupdesc;
+
+ tupdesc = CreateTemplateTupleDesc(MAX_CONFLICT_ATTR_NUM);
+
+ for (int i = 0; i < MAX_CONFLICT_ATTR_NUM; i++)
+ {
+ Oid type_oid = ConflictLogSchema[i].atttypid;
+
+ /*
+ * Special handling for the JSON array type for proper
+ * TupleDescInitEntry call.
+ */
+ if (type_oid == JSONARRAYOID)
+ type_oid = get_array_type(JSONOID);
+
+ TupleDescInitEntry(tupdesc, i + 1,
+ ConflictLogSchema[i].attname,
+ type_oid,
+ -1, 0);
+ }
+
+ return BlessTupleDesc(tupdesc);
+}
+
+/*
+ * Create a structured conflict log table for a subscription.
+ *
+ * The table is created within the system-managed 'pg_conflict' namespace.
+ * The table name is generated automatically using the subscription's OID
+ * (e.g., "pg_conflict_<subid>") to ensure uniqueness within the cluster and
+ * to avoid collisions during subscription renames.
+ */
+static Oid
+create_conflict_log_table(Oid subid, char *subname)
+{
+ TupleDesc tupdesc;
+ Oid relid;
+ ObjectAddress myself;
+ ObjectAddress subaddr;
+ char relname[NAMEDATALEN];
+
+ snprintf(relname, NAMEDATALEN, "pg_conflict_%u", subid);
+
+ /* There can not be an existing table with the same name. */
+ Assert(!OidIsValid(get_relname_relid(relname, PG_CONFLICT_NAMESPACE)));
+
+ /* Build the tuple descriptor for the new table. */
+ tupdesc = create_conflict_log_table_tupdesc();
+
+ /* Create conflict log table. */
+ relid = heap_create_with_catalog(relname,
+ PG_CONFLICT_NAMESPACE,
+ 0, /* tablespace */
+ InvalidOid, /* relid */
+ InvalidOid, /* reltypeid */
+ InvalidOid, /* reloftypeid */
+ GetUserId(),
+ HEAP_TABLE_AM_OID,
+ tupdesc,
+ NIL,
+ RELKIND_RELATION,
+ RELPERSISTENCE_PERMANENT,
+ false, /* shared_relation */
+ false, /* mapped_relation */
+ ONCOMMIT_NOOP,
+ (Datum) 0, /* reloptions */
+ false, /* use_user_acl */
+ true, /* allow_system_table_mods */
+ true, /* is_internal */
+ InvalidOid, /* relrewrite */
+ NULL); /* typaddress */
+
+ /*
+ * Establish an internal dependency between the conflict log table and
+ * the subscription.
+ *
+ * We use DEPENDENCY_INTERNAL to signify that the table's lifecycle is
+ * strictly tied to the subscription, similar to how a TOAST table relates
+ * to its main table or a sequence relates to an identity column.
+ *
+ * This ensures the conflict log table is automatically reaped during a
+ * DROP SUBSCRIPTION via performDeletion().
+ */
+ ObjectAddressSet(myself, RelationRelationId, relid);
+ ObjectAddressSet(subaddr, SubscriptionRelationId, subid);
+ recordDependencyOn(&myself, &subaddr, DEPENDENCY_INTERNAL);
+
+ /* Release tuple descriptor memory. */
+ FreeTupleDesc(tupdesc);
+
+ return relid;
+}
+
+/*
+ * GetLogDestination
+ *
+ * Convert string to enum by comparing against standardized labels.
+ */
+ConflictLogDest
+GetLogDestination(const char *dest)
+{
+ /* Empty string or NULL defaults to LOG. */
+ if (dest == NULL || dest[0] == '\0' || pg_strcasecmp(dest, "log") == 0)
+ return CONFLICT_LOG_DEST_LOG;
+
+ if (pg_strcasecmp(dest, "table") == 0)
+ return CONFLICT_LOG_DEST_TABLE;
+
+ if (pg_strcasecmp(dest, "all") == 0)
+ return CONFLICT_LOG_DEST_ALL;
+
+ /* Unrecognized string. */
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("unrecognized conflict_log_destination value: \"%s\"", dest),
+ errhint("Valid values are \"log\", \"table\", and \"all\".")));
+}
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index f976c0e5c7e..4bf39f359d7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -2398,9 +2398,11 @@ truncate_check_rel(Oid relid, Form_pg_class reltuple)
* pg_largeobject and pg_largeobject_metadata to be truncated as part of
* pg_upgrade, because we need to change its relfilenode to match the old
* cluster, and allowing a TRUNCATE command to be executed is the easiest
- * way of doing that.
+ * way of doing that. We also allow TRUNCATE on the conflict log tables,
+ * to permit users to manually prune these logs to manage disk space.
*/
- if (!allowSystemTableMods && IsSystemClass(relid, reltuple)
+ if (!allowSystemTableMods && IsSystemClass(relid, reltuple) &&
+ !IsConflictClass(reltuple)
&& (!IsBinaryUpgrade ||
(relid != LargeObjectRelationId &&
relid != LargeObjectMetadataRelationId)))
diff --git a/src/backend/executor/execMain.c b/src/backend/executor/execMain.c
index bfd3ebc601e..4c001aa0734 100644
--- a/src/backend/executor/execMain.c
+++ b/src/backend/executor/execMain.c
@@ -1170,6 +1170,21 @@ CheckValidResultRel(ResultRelInfo *resultRelInfo, CmdType operation,
RelationGetRelationName(resultRel))));
break;
}
+
+ /*
+ * Conflict log tables are managed by the system to record logical
+ * replication conflicts. We allow DELETE to permit users to manually
+ * prune or truncate these logs, but manual data insertion or modification
+ * (INSERT, UPDATE, MERGE) is prohibited to maintain the integrity of the
+ * system-generated logs.
+ */
+ if (IsConflictNamespace(RelationGetNamespace(resultRel)) &&
+ operation != CMD_DELETE)
+ ereport(ERROR,
+ (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+ errmsg("cannot modify or insert data into conflict log table \"%s\"",
+ RelationGetRelationName(resultRel)),
+ errdetail("Conflict log tables are system-managed and only support cleanup via DELETE or TRUNCATE.")));
}
/*
@@ -1234,6 +1249,17 @@ CheckValidRowMarkRel(Relation rel, RowMarkType markType)
RelationGetRelationName(rel))));
break;
}
+
+ /*
+ * Conflict log tables are managed by the system to record logical
+ * replication conflicts. We do not allow locking rows in CONFLICT
+ * relations.
+ */
+ if (IsConflictNamespace(RelationGetNamespace(rel)))
+ ereport(ERROR,
+ (errcode(ERRCODE_WRONG_OBJECT_TYPE),
+ errmsg("cannot lock rows in conflict log table \"%s\"",
+ RelationGetRelationName(rel))));
}
/*
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index 3584c4e1428..21cfe390430 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -6806,7 +6806,7 @@ describeSubscriptions(const char *pattern, bool verbose)
printQueryOpt myopt = pset.popt;
static const bool translate_columns[] = {false, false, false, false,
false, false, false, false, false, false, false, false, false, false,
- false, false, false, false};
+ false, false, false, false, false, false};
if (pset.sversion < 100000)
{
@@ -6900,6 +6900,20 @@ describeSubscriptions(const char *pattern, bool verbose)
appendPQExpBuffer(&buf,
", subskiplsn AS \"%s\"\n",
gettext_noop("Skip LSN"));
+
+ /* Conflict log destination is supported in v19 and higher */
+ if (pset.sversion >= 190000)
+ {
+ appendPQExpBuffer(&buf,
+ ", subconflictlogdest AS \"%s\"\n",
+ gettext_noop("Conflict log destination"));
+
+ appendPQExpBuffer(&buf,
+ ", (CASE WHEN subconflictlogdest IN ('table', 'all') "
+ " THEN 'pg_conflict_' || oid "
+ " ELSE '-' END) AS \"%s\"\n",
+ gettext_noop("Conflict log table"));
+ }
}
/* Only display subscriptions in current database. */
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 8b91bc00062..12eee8a0d43 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -2344,8 +2344,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("(", "PUBLICATION");
/* ALTER SUBSCRIPTION <name> SET ( */
else if (Matches("ALTER", "SUBSCRIPTION", MatchAny, MatchAnyN, "SET", "("))
- COMPLETE_WITH("binary", "disable_on_error", "failover",
- "max_retention_duration", "origin",
+ COMPLETE_WITH("binary", "conflict_log_destination", "disable_on_error",
+ "failover", "max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
"synchronous_commit", "two_phase");
@@ -3860,8 +3860,8 @@ match_previous_words(int pattern_id,
COMPLETE_WITH("WITH (");
/* Complete "CREATE SUBSCRIPTION <name> ... WITH ( <opt>" */
else if (Matches("CREATE", "SUBSCRIPTION", MatchAnyN, "WITH", "("))
- COMPLETE_WITH("binary", "connect", "copy_data", "create_slot",
- "disable_on_error", "enabled", "failover",
+ COMPLETE_WITH("binary", "conflict_log_destination", "connect", "copy_data",
+ "create_slot", "disable_on_error", "enabled", "failover",
"max_retention_duration", "origin",
"password_required", "retain_dead_tuples",
"run_as_owner", "slot_name", "streaming",
diff --git a/src/include/catalog/catalog.h b/src/include/catalog/catalog.h
index a9d6e8ea986..8193229f2e2 100644
--- a/src/include/catalog/catalog.h
+++ b/src/include/catalog/catalog.h
@@ -25,6 +25,7 @@ extern bool IsInplaceUpdateRelation(Relation relation);
extern bool IsSystemClass(Oid relid, Form_pg_class reltuple);
extern bool IsToastClass(Form_pg_class reltuple);
+extern bool IsConflictClass(Form_pg_class reltuple);
extern bool IsCatalogRelationOid(Oid relid);
extern bool IsCatalogTextUniqueIndexOid(Oid relid);
@@ -32,6 +33,7 @@ extern bool IsInplaceUpdateOid(Oid relid);
extern bool IsCatalogNamespace(Oid namespaceId);
extern bool IsToastNamespace(Oid namespaceId);
+extern bool IsConflictNamespace(Oid namespaceId);
extern bool IsReservedName(const char *name);
diff --git a/src/include/catalog/pg_namespace.dat b/src/include/catalog/pg_namespace.dat
index 3075e142c73..b45cb9383a8 100644
--- a/src/include/catalog/pg_namespace.dat
+++ b/src/include/catalog/pg_namespace.dat
@@ -18,6 +18,9 @@
{ oid => '99', oid_symbol => 'PG_TOAST_NAMESPACE',
descr => 'reserved schema for TOAST tables',
nspname => 'pg_toast', nspacl => '_null_' },
+{ oid => '1382', oid_symbol => 'PG_CONFLICT_NAMESPACE',
+ descr => 'reserved schema for subscription-specific conflict log tables',
+ nspname => 'pg_conflict', nspacl => '_null_' },
# update dumpNamespace() if changing this descr
{ oid => '2200', oid_symbol => 'PG_PUBLIC_NAMESPACE',
descr => 'standard public schema',
diff --git a/src/include/catalog/pg_subscription.h b/src/include/catalog/pg_subscription.h
index f3571d2bfcf..76a4638b389 100644
--- a/src/include/catalog/pg_subscription.h
+++ b/src/include/catalog/pg_subscription.h
@@ -90,6 +90,7 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
* exceeded max_retention_duration, when
* defined */
+ Oid subconflictlogrelid; /* Relid of the conflict log table. */
#ifdef CATALOG_VARLEN /* variable-length fields start here */
/* Connection string to the publisher */
text subconninfo BKI_FORCE_NOT_NULL;
@@ -103,6 +104,14 @@ CATALOG(pg_subscription,6100,SubscriptionRelationId) BKI_SHARED_RELATION BKI_ROW
/* List of publications subscribed to */
text subpublications[1] BKI_FORCE_NOT_NULL;
+ /*
+ * Strategy for logging replication conflicts:
+ * 'log' - server log only,
+ * 'table' - conflict log table only,
+ * 'all' - both log and table.
+ */
+ text subconflictlogdest;
+
/* Only publish data originating from the specified origin */
text suborigin BKI_DEFAULT(LOGICALREP_ORIGIN_ANY);
#endif
@@ -152,12 +161,14 @@ typedef struct Subscription
* and the retention duration has not
* exceeded max_retention_duration, when
* defined */
+ Oid conflictlogrelid; /* conflict log table Oid */
char *conninfo; /* Connection string to the publisher */
char *slotname; /* Name of the replication slot */
char *synccommit; /* Synchronous commit setting for worker */
List *publications; /* List of publication names to subscribe to */
char *origin; /* Only publish data originating from the
* specified origin */
+ char *conflictlogdest; /* Conflict log destination */
} Subscription;
#ifdef EXPOSE_TO_CLIENT_CODE
diff --git a/src/include/commands/subscriptioncmds.h b/src/include/commands/subscriptioncmds.h
index 63504232a14..a895127f8fe 100644
--- a/src/include/commands/subscriptioncmds.h
+++ b/src/include/commands/subscriptioncmds.h
@@ -17,6 +17,7 @@
#include "catalog/objectaddress.h"
#include "parser/parse_node.h"
+#include "replication/conflict.h"
extern ObjectAddress CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
bool isTopLevel);
@@ -36,4 +37,6 @@ extern void CheckSubDeadTupleRetention(bool check_guc, bool sub_disabled,
bool retention_active,
bool max_retention_set);
+extern ConflictLogDest GetLogDestination(const char *dest);
+
#endif /* SUBSCRIPTIONCMDS_H */
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index 2de7b624eb2..31facc1ec7e 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -10,6 +10,7 @@
#define CONFLICT_H
#include "access/xlogdefs.h"
+#include "catalog/pg_type.h"
#include "nodes/pg_list.h"
#include "utils/timestamp.h"
@@ -79,6 +80,60 @@ typedef struct ConflictTupleInfo
* conflicting local row occurred */
} ConflictTupleInfo;
+/*
+ * Conflict log destination types.
+ *
+ * These values are defined as bitmask flags to allow for multiple simultaneous
+ * logging destinations (e.g., logging to both system logs and a table).
+ * Internally, we use these for bitwise comparisons (IsSet), but the string
+ * representation is stored in pg_subscription.subconflictlogdest.
+ */
+typedef enum ConflictLogDest
+{
+ /* Log conflicts to the server logs */
+ CONFLICT_LOG_DEST_LOG = 1 << 0, /* 0x01 */
+
+ /* Log conflicts to an internally managed conflict log table */
+ CONFLICT_LOG_DEST_TABLE = 1 << 1, /* 0x02 */
+
+ /* Convenience bitmask for all supported destinations */
+ CONFLICT_LOG_DEST_ALL = (CONFLICT_LOG_DEST_LOG | CONFLICT_LOG_DEST_TABLE)
+} ConflictLogDest;
+
+/*
+ * Array mapping for converting internal enum to string.
+ */
+static const char *const ConflictLogDestNames[] = {
+ [CONFLICT_LOG_DEST_LOG] = "log",
+ [CONFLICT_LOG_DEST_TABLE] = "table",
+ [CONFLICT_LOG_DEST_ALL] = "all"
+};
+
+/* Structure to hold metadata for one column of the conflict log table */
+typedef struct ConflictLogColumnDef
+{
+ const char *attname; /* Column name */
+ Oid atttypid; /* Data type OID */
+} ConflictLogColumnDef;
+
+/* The single source of truth for the conflict log table schema */
+static const ConflictLogColumnDef ConflictLogSchema[] =
+{
+ { .attname = "relid", .atttypid = OIDOID },
+ { .attname = "schemaname", .atttypid = TEXTOID },
+ { .attname = "relname", .atttypid = TEXTOID },
+ { .attname = "conflict_type", .atttypid = TEXTOID },
+ { .attname = "remote_xid", .atttypid = XIDOID },
+ { .attname = "remote_commit_lsn",.atttypid = LSNOID },
+ { .attname = "remote_commit_ts", .atttypid = TIMESTAMPTZOID },
+ { .attname = "remote_origin", .atttypid = TEXTOID },
+ { .attname = "replica_identity", .atttypid = JSONOID },
+ { .attname = "remote_tuple", .atttypid = JSONOID },
+ { .attname = "local_conflicts", .atttypid = JSONARRAYOID }
+};
+
+#define MAX_CONFLICT_ATTR_NUM lengthof(ConflictLogSchema)
+
extern bool GetTupleTransactionInfo(TupleTableSlot *localslot,
TransactionId *xmin,
RepOriginId *localorigin,
diff --git a/src/test/regress/expected/subscription.out b/src/test/regress/expected/subscription.out
index b3eccd8afe3..b94bcc3cc23 100644
--- a/src/test/regress/expected/subscription.out
+++ b/src/test/regress/expected/subscription.out
@@ -116,18 +116,18 @@ CREATE SUBSCRIPTION regress_testsub4 CONNECTION 'dbname=regress_doesnotexist' PU
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | none | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub4 SET (origin = any);
\dRs+ regress_testsub4
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
-------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub4 | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
DROP SUBSCRIPTION regress_testsub3;
@@ -145,10 +145,10 @@ ALTER SUBSCRIPTION regress_testsub CONNECTION 'foobar';
ERROR: invalid connection string syntax: missing "=" after "foobar" in connection info string
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET PUBLICATION testpub2, testpub3 WITH (refresh = false);
@@ -157,10 +157,10 @@ ALTER SUBSCRIPTION regress_testsub SET (slot_name = 'newname');
ALTER SUBSCRIPTION regress_testsub SET (password_required = false);
ALTER SUBSCRIPTION regress_testsub SET (run_as_owner = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | f | t | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (password_required = true);
@@ -176,10 +176,10 @@ ERROR: unrecognized subscription parameter: "create_slot"
-- ok
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/12345');
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00012345 | log | -
(1 row)
-- ok - with lsn = NONE
@@ -188,10 +188,10 @@ ALTER SUBSCRIPTION regress_testsub SKIP (lsn = NONE);
ALTER SUBSCRIPTION regress_testsub SKIP (lsn = '0/0');
ERROR: invalid WAL location (LSN): 0/0
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist2 | 0/00000000 | log | -
(1 row)
BEGIN;
@@ -223,10 +223,10 @@ ALTER SUBSCRIPTION regress_testsub_foo SET (synchronous_commit = foobar);
ERROR: invalid value for parameter "synchronous_commit": "foobar"
HINT: Available values: local, remote_write, remote_apply, on, off.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
----------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------
- regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+---------------------+---------------------------+---------+---------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+------------------------------+------------+--------------------------+--------------------
+ regress_testsub_foo | regress_subscription_user | f | {testpub2,testpub3} | f | parallel | d | f | any | t | f | f | f | 0 | f | local | dbname=regress_doesnotexist2 | 0/00000000 | log | -
(1 row)
-- rename back to keep the rest simple
@@ -255,19 +255,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | t | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (binary = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -279,27 +279,27 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = parallel);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (streaming = false);
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
-- fail - publication already exists
@@ -314,10 +314,10 @@ ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refr
ALTER SUBSCRIPTION regress_testsub ADD PUBLICATION testpub1, testpub2 WITH (refresh = false);
ERROR: publication "testpub1" is already in subscription "regress_testsub"
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-----------------------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub,testpub1,testpub2} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
-- fail - publication used more than once
@@ -332,10 +332,10 @@ ERROR: publication "testpub3" is not in subscription "regress_testsub"
-- ok - delete publications
ALTER SUBSCRIPTION regress_testsub DROP PUBLICATION testpub1, testpub2 WITH (refresh = false);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | off | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
DROP SUBSCRIPTION regress_testsub;
@@ -371,19 +371,19 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
-- we can alter streaming when two_phase enabled
ALTER SUBSCRIPTION regress_testsub SET (streaming = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -393,10 +393,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | on | p | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -409,18 +409,18 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (disable_on_error = true);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | t | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -433,10 +433,10 @@ CREATE SUBSCRIPTION regress_testsub CONNECTION 'dbname=regress_doesnotexist' PUB
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -450,19 +450,19 @@ NOTICE: max_retention_duration is ineffective when retain_dead_tuples is disabl
WARNING: subscription was created, but is not connected
HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 1000 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
-- ok
ALTER SUBSCRIPTION regress_testsub SET (max_retention_duration = 0);
\dRs+
- List of subscriptions
- Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN
------------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------
- regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000
+ List of subscriptions
+ Name | Owner | Enabled | Publication | Binary | Streaming | Two-phase commit | Disable on error | Origin | Password required | Run as owner? | Failover | Retain dead tuples | Max retention duration | Retention active | Synchronous commit | Conninfo | Skip LSN | Conflict log destination | Conflict log table
+-----------------+---------------------------+---------+-------------+--------+-----------+------------------+------------------+--------+-------------------+---------------+----------+--------------------+------------------------+------------------+--------------------+-----------------------------+------------+--------------------------+--------------------
+ regress_testsub | regress_subscription_user | f | {testpub} | f | parallel | d | f | any | t | f | f | f | 0 | f | off | dbname=regress_doesnotexist | 0/00000000 | log | -
(1 row)
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
@@ -517,6 +517,274 @@ COMMIT;
-- ok, owning it is enough for this stuff
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG DESTINATION TESTS
+--
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+-- fail - unrecognized parameter value
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
+ERROR: unrecognized conflict_log_destination value: "invalid"
+HINT: Valid values are "log", "table", and "all".
+-- verify subconflictlogdest is 'log' and relid is 0 (InvalidOid) for default case
+CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
+ subname | subconflictlogdest | subconflictlogrelid
+------------------------------+--------------------+---------------------
+ regress_conflict_log_default | log | 0
+(1 row)
+
+-- verify empty string defaults to 'log'
+CREATE SUBSCRIPTION regress_conflict_empty_str CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = '');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
+ subname | subconflictlogdest | subconflictlogrelid
+----------------------------+--------------------+---------------------
+ regress_conflict_empty_str | log | 0
+(1 row)
+
+-- this should generate an internal conflict log table named pg_conflict_$subid$
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- check metadata in pg_subscription: destination should be 'table' and relid valid
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+ subname | subconflictlogdest | has_relid
+------------------------+--------------------+-----------
+ regress_conflict_test1 | table | t
+(1 row)
+
+-- verify the physical table exists, its OID matches subconflictlogrelid,
+-- and it is located in the 'pg_conflict' namespace
+SELECT n.nspname, (c.oid = s.subconflictlogrelid) AS "oid_matches"
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+JOIN pg_namespace n ON c.relnamespace = n.oid
+WHERE s.subname = 'regress_conflict_test1';
+ nspname | oid_matches
+-------------+-------------
+ pg_conflict | t
+(1 row)
+
+-- check if the conflict log table has the correct schema
+SELECT a.attnum, a.attname
+FROM pg_attribute a
+JOIN pg_class c ON a.attrelid = c.oid
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0
+ ORDER BY a.attnum;
+ attnum | attname
+--------+-------------------
+ 1 | relid
+ 2 | schemaname
+ 3 | relname
+ 4 | conflict_type
+ 5 | remote_xid
+ 6 | remote_commit_lsn
+ 7 | remote_commit_ts
+ 8 | remote_origin
+ 9 | replica_identity
+ 10 | remote_tuple
+ 11 | local_conflicts
+(11 rows)
+
+--
+-- ALTER SUBSCRIPTION - conflict_log_destination state transitions
+--
+-- These tests verify the transition logic between different logging
+-- destinations, ensuring conflict log tables are created or dropped as
+-- expected
+--
+-- transition from 'log' to 'all'
+-- a new internal conflict log table should be created
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
+-- verify metadata after ALTER (destination should be 'all')
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subname | subconflictlogdest | has_relid
+------------------------+--------------------+-----------
+ regress_conflict_test2 | all | t
+(1 row)
+
+-- transition from 'all' to 'table'
+-- should NOT drop the table, only change destination string
+SELECT subconflictlogrelid AS old_relid FROM pg_subscription WHERE subname = 'regress_conflict_test2' \gset
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'table');
+SELECT subconflictlogdest, subconflictlogrelid = :old_relid AS relid_unchanged
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subconflictlogdest | relid_unchanged
+--------------------+-----------------
+ table | t
+(1 row)
+
+-- transition from 'table' to 'log'
+-- should drop the table and clear relid
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log');
+SELECT subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+ subconflictlogdest | subconflictlogrelid
+--------------------+---------------------
+ log | 0
+(1 row)
+
+-- verify the physical table is gone
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test2';
+ count
+-------
+ 0
+(1 row)
+
+--
+-- PUBLICATION: Verify conflict log tables are not publishable
+--
+-- pg_relation_is_publishable should return false for internal conflict log
+-- tables to prevent them from being accidentally included in publications
+--
+SELECT n.nspname, pg_relation_is_publishable(c.oid)
+FROM pg_class c
+JOIN pg_namespace n ON c.relnamespace = n.oid
+JOIN pg_subscription s ON s.subconflictlogrelid = c.oid
+WHERE s.subname = 'regress_conflict_test1';
+ nspname | pg_relation_is_publishable
+-------------+----------------------------
+ pg_conflict | f
+(1 row)
+
+--
+-- Table Protection and Lifecycle Management
+--
+-- These tests verify that:
+-- Manual DROP TABLE is disallowed
+-- DROP SUBSCRIPTION automatically reaps the table
+--
+-- re-enable table logging for verification
+ALTER SUBSCRIPTION regress_conflict_test1 SET (conflict_log_destination = 'table');
+-- We use a DO block with dynamic SQL because the internal conflict log table
+-- name contains the subscription OID, which is non-deterministic. This
+-- approach allows us to attempt the DROP and capture the expected error
+-- without hard-coding a specific OID in the expected output
+-- fail - drop table not allowed due to internal dependency
+DO $$
+BEGIN
+ EXECUTE 'DROP TABLE ' || (SELECT 'pg_conflict.pg_conflict_' || oid FROM pg_subscription WHERE subname = 'regress_conflict_test1');
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege';
+END $$;
+NOTICE: captured expected error: insufficient_privilege
+-- CLEANUP: DROP SUBSCRIPTION reaps the table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+-- Verify the table OID for reap check
+SELECT 'pg_conflict_' || oid AS internal_tablename FROM pg_subscription WHERE subname = 'regress_conflict_test1' \gset
+DROP SUBSCRIPTION regress_conflict_test1;
+-- should return NULL, meaning the conflict log table was reaped via dependency
+SELECT to_regclass(:'internal_tablename');
+ to_regclass
+-------------
+
+(1 row)
+
+--
+-- Additional Namespace and Table Protection Tests
+--
+-- Setup: Ensure we have a subscription with a conflict log table
+CREATE SUBSCRIPTION regress_conflict_protection_test CONNECTION 'dbname=regress_doesnotexist'
+ PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+WARNING: subscription was created, but is not connected
+HINT: To initiate replication, you must manually create the replication slot, enable the subscription, and alter the subscription to refresh publications.
+-- Trying to ALTER the internal conflict log table
+-- This should fail because the table is system-managed
+-- As mentioned in previous test cases, we use a DO block to hide dynamic OIDs
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ RAISE NOTICE 'Attempting ALTER TABLE on internal conflict log table';
+ EXECUTE 'ALTER TABLE ' || tab_name || ' ADD COLUMN extra_info text';
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during ALTER';
+END $$;
+NOTICE: Attempting ALTER TABLE on internal conflict log table
+NOTICE: captured expected error: insufficient_privilege during ALTER
+-- Test Manual INSERT on conflict log table
+-- This should fail because the table is system-managed
+-- Hiding the OID in the error message by catching the exception
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ EXECUTE 'INSERT INTO ' || tab_name || ' (relname) VALUES (''mytest'')';
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during INSERT';
+END $$;
+NOTICE: captured expected error: insufficient_privilege during INSERT
+-- Test Manual UPDATE on conflict log table
+-- This should fail because the table is system-managed
+-- Hiding the OID in the error message by catching the exception
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ EXECUTE 'UPDATE ' || tab_name || ' SET relname = ''mytest'' ';
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during UPDATE';
+END $$;
+NOTICE: captured expected error: insufficient_privilege during UPDATE
+-- Trying to perform TRUNCATE/DELETE on the internal conflict log table
+-- This should be allowed so that user can perform cleanup
+SELECT 'pg_conflict.' || relname AS conflict_tab
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_protection_test' \gset
+TRUNCATE :conflict_tab;
+DELETE FROM :conflict_tab;
+-- Trying to create a new table manually in the pg_conflict namespace
+-- This should fail as the namespace is reserved for conflict logs tables
+CREATE TABLE pg_conflict.manual_table (id int);
+ERROR: permission denied to create "pg_conflict.manual_table"
+DETAIL: System catalog modifications are currently disallowed.
+-- Moving an existing table into the pg_conflict namespace
+-- Users should not be able to move their own tables within this namespace
+CREATE TABLE public.test_move (id int);
+ALTER TABLE public.test_move SET SCHEMA pg_conflict;
+ERROR: cannot move objects into or out of CONFLICT schema
+DROP TABLE public.test_move;
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_log_default DISABLE;
+ALTER SUBSCRIPTION regress_conflict_log_default SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_log_default;
+ALTER SUBSCRIPTION regress_conflict_empty_str DISABLE;
+ALTER SUBSCRIPTION regress_conflict_empty_str SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_empty_str;
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+ALTER SUBSCRIPTION regress_conflict_protection_test DISABLE;
+ALTER SUBSCRIPTION regress_conflict_protection_test SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_protection_test;
RESET SESSION AUTHORIZATION;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
diff --git a/src/test/regress/sql/subscription.sql b/src/test/regress/sql/subscription.sql
index ef0c298d2df..d2934478392 100644
--- a/src/test/regress/sql/subscription.sql
+++ b/src/test/regress/sql/subscription.sql
@@ -365,6 +365,223 @@ COMMIT;
ALTER SUBSCRIPTION regress_testsub SET (slot_name = NONE);
DROP SUBSCRIPTION regress_testsub;
+--
+-- CONFLICT LOG DESTINATION TESTS
+--
+
+SET SESSION AUTHORIZATION 'regress_subscription_user';
+
+-- fail - unrecognized parameter value
+CREATE SUBSCRIPTION regress_conflict_fail CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'invalid');
+
+-- verify subconflictlogdest is 'log' and relid is 0 (InvalidOid) for default case
+CREATE SUBSCRIPTION regress_conflict_log_default CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false);
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_log_default';
+
+-- verify empty string defaults to 'log'
+CREATE SUBSCRIPTION regress_conflict_empty_str CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = '');
+SELECT subname, subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_empty_str';
+
+-- this should generate an internal conflict log table named pg_conflict_$subid$
+CREATE SUBSCRIPTION regress_conflict_test1 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+
+-- check metadata in pg_subscription: destination should be 'table' and relid valid
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test1';
+
+-- verify the physical table exists, its OID matches subconflictlogrelid,
+-- and it is located in the 'pg_conflict' namespace
+SELECT n.nspname, (c.oid = s.subconflictlogrelid) AS "oid_matches"
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+JOIN pg_namespace n ON c.relnamespace = n.oid
+WHERE s.subname = 'regress_conflict_test1';
+
+-- check if the conflict log table has the correct schema
+SELECT a.attnum, a.attname
+FROM pg_attribute a
+JOIN pg_class c ON a.attrelid = c.oid
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test1' AND a.attnum > 0
+ ORDER BY a.attnum;
+
+--
+-- ALTER SUBSCRIPTION - conflict_log_destination state transitions
+--
+-- These tests verify the transition logic between different logging
+-- destinations, ensuring conflict log tables are created or dropped as
+-- expected
+--
+-- transition from 'log' to 'all'
+-- a new internal conflict log table should be created
+CREATE SUBSCRIPTION regress_conflict_test2 CONNECTION 'dbname=regress_doesnotexist' PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'log');
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'all');
+
+-- verify metadata after ALTER (destination should be 'all')
+SELECT subname, subconflictlogdest, subconflictlogrelid > 0 AS has_relid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'all' to 'table'
+-- should NOT drop the table, only change destination string
+SELECT subconflictlogrelid AS old_relid FROM pg_subscription WHERE subname = 'regress_conflict_test2' \gset
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'table');
+SELECT subconflictlogdest, subconflictlogrelid = :old_relid AS relid_unchanged
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- transition from 'table' to 'log'
+-- should drop the table and clear relid
+ALTER SUBSCRIPTION regress_conflict_test2 SET (conflict_log_destination = 'log');
+SELECT subconflictlogdest, subconflictlogrelid
+FROM pg_subscription WHERE subname = 'regress_conflict_test2';
+
+-- verify the physical table is gone
+SELECT count(*)
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_test2';
+
+--
+-- PUBLICATION: Verify conflict log tables are not publishable
+--
+-- pg_relation_is_publishable should return false for internal conflict log
+-- tables to prevent them from being accidentally included in publications
+--
+SELECT n.nspname, pg_relation_is_publishable(c.oid)
+FROM pg_class c
+JOIN pg_namespace n ON c.relnamespace = n.oid
+JOIN pg_subscription s ON s.subconflictlogrelid = c.oid
+WHERE s.subname = 'regress_conflict_test1';
+
+--
+-- Table Protection and Lifecycle Management
+--
+-- These tests verify that:
+-- Manual DROP TABLE is disallowed
+-- DROP SUBSCRIPTION automatically reaps the table
+--
+-- re-enable table logging for verification
+ALTER SUBSCRIPTION regress_conflict_test1 SET (conflict_log_destination = 'table');
+
+-- We use a DO block with dynamic SQL because the internal conflict log table
+-- name contains the subscription OID, which is non-deterministic. This
+-- approach allows us to attempt the DROP and capture the expected error
+-- without hard-coding a specific OID in the expected output
+
+-- fail - drop table not allowed due to internal dependency
+DO $$
+BEGIN
+ EXECUTE 'DROP TABLE ' || (SELECT 'pg_conflict.pg_conflict_' || oid FROM pg_subscription WHERE subname = 'regress_conflict_test1');
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege';
+END $$;
+
+-- CLEANUP: DROP SUBSCRIPTION reaps the table
+ALTER SUBSCRIPTION regress_conflict_test1 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test1 SET (slot_name = NONE);
+
+-- Verify the table OID for reap check
+SELECT 'pg_conflict_' || oid AS internal_tablename FROM pg_subscription WHERE subname = 'regress_conflict_test1' \gset
+
+DROP SUBSCRIPTION regress_conflict_test1;
+
+-- should return NULL, meaning the conflict log table was reaped via dependency
+SELECT to_regclass(:'internal_tablename');
+
+--
+-- Additional Namespace and Table Protection Tests
+--
+
+-- Setup: Ensure we have a subscription with a conflict log table
+CREATE SUBSCRIPTION regress_conflict_protection_test CONNECTION 'dbname=regress_doesnotexist'
+ PUBLICATION testpub WITH (connect = false, conflict_log_destination = 'table');
+
+-- Trying to ALTER the internal conflict log table
+-- This should fail because the table is system-managed
+-- As mentioned in previous test cases, we use a DO block to hide dynamic OIDs
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ RAISE NOTICE 'Attempting ALTER TABLE on internal conflict log table';
+ EXECUTE 'ALTER TABLE ' || tab_name || ' ADD COLUMN extra_info text';
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during ALTER';
+END $$;
+
+-- Test Manual INSERT on conflict log table
+-- This should fail because the table is system-managed
+-- Hiding the OID in the error message by catching the exception
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ EXECUTE 'INSERT INTO ' || tab_name || ' (relname) VALUES (''mytest'')';
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during INSERT';
+END $$;
+
+-- Test Manual UPDATE on conflict log table
+-- This should fail because the table is system-managed
+-- Hiding the OID in the error message by catching the exception
+DO $$
+DECLARE
+ tab_name text;
+BEGIN
+ SELECT 'pg_conflict.' || relname INTO tab_name
+ FROM pg_class c JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+ WHERE s.subname = 'regress_conflict_protection_test';
+
+ EXECUTE 'UPDATE ' || tab_name || ' SET relname = ''mytest'' ';
+EXCEPTION WHEN insufficient_privilege THEN
+ RAISE NOTICE 'captured expected error: insufficient_privilege during UPDATE';
+END $$;
+
+-- Trying to perform TRUNCATE/DELETE on the internal conflict log table
+-- This should be allowed so that user can perform cleanup
+SELECT 'pg_conflict.' || relname AS conflict_tab
+FROM pg_class c
+JOIN pg_subscription s ON c.relname = 'pg_conflict_' || s.oid
+WHERE s.subname = 'regress_conflict_protection_test' \gset
+TRUNCATE :conflict_tab;
+DELETE FROM :conflict_tab;
+
+-- Trying to create a new table manually in the pg_conflict namespace
+-- This should fail as the namespace is reserved for conflict logs tables
+CREATE TABLE pg_conflict.manual_table (id int);
+
+-- Moving an existing table into the pg_conflict namespace
+-- Users should not be able to move their own tables within this namespace
+CREATE TABLE public.test_move (id int);
+ALTER TABLE public.test_move SET SCHEMA pg_conflict;
+DROP TABLE public.test_move;
+
+-- Clean up remaining test subscription
+ALTER SUBSCRIPTION regress_conflict_log_default DISABLE;
+ALTER SUBSCRIPTION regress_conflict_log_default SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_log_default;
+
+ALTER SUBSCRIPTION regress_conflict_empty_str DISABLE;
+ALTER SUBSCRIPTION regress_conflict_empty_str SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_empty_str;
+
+ALTER SUBSCRIPTION regress_conflict_test2 DISABLE;
+ALTER SUBSCRIPTION regress_conflict_test2 SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_test2;
+
+ALTER SUBSCRIPTION regress_conflict_protection_test DISABLE;
+ALTER SUBSCRIPTION regress_conflict_protection_test SET (slot_name = NONE);
+DROP SUBSCRIPTION regress_conflict_protection_test;
+
RESET SESSION AUTHORIZATION;
DROP ROLE regress_subscription_user;
DROP ROLE regress_subscription_user2;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 1c8610fd46c..6e2873b9c99 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -502,6 +502,8 @@ ConditionVariableMinimallyPadded
ConditionalStack
ConfigData
ConfigVariable
+ConflictLogColumnDef
+ConflictLogDest
ConflictTupleInfo
ConflictType
ConnCacheEntry
--
2.49.0
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-28 04:13 Peter Smith <smithpb2250@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
1 sibling, 0 replies; 912+ messages in thread
From: Peter Smith @ 2026-01-28 04:13 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: vignesh C <vignesh21@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; shveta malik <shveta.malik@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Some review comments for the v25* patches:
//////////
Patch v25-0001
//////////
1.
+ /*
+ * Conflict log tables are managed by the system to record logical
+ * replication conflicts. We do not allow locking rows in CONFLICT
+ * relations.
+ */
+ if (IsConflictNamespace(RelationGetNamespace(rel)))
+ ereport(ERROR,
+ (errcode(ERRCODE_WRONG_OBJECT_TYPE),
+ errmsg("cannot lock rows in conflict log table \"%s\"",
+ RelationGetRelationName(rel))));
I felt the code comment should be changed, too.
e.g. That comment sentence "We do not allow locking rows in CONFLICT
relations" seemed redundant because you already said the CLT is
"managed by the system", and also it is basically just repeating the
same info as the error message.
Suggest either:
a) remove it, or
b) change /CONFLICT/pg_conflict namespace/
//////////
Patch v25-0002
//////////
No review comments.
//////////
Patch v25-0003 (docs)
//////////
======
doc/src/sgml/logical-replication.sgml
(29.8 Conflicts)
1.
There is an earlier sentence on this page:
"Note that there are other conflict scenarios, such as exclusion
constraint violations. Currently, we do not provide additional details
for them in the log."
~
That "in the log part" wording maybe needs to be changed/removed now
because the destination might be a CLT, not a log.
SUGGESTION:
Currently, we do not provide additional details for them.
~~~
2.
<para>
- The log format for logical replication conflicts is as follows:
+ The <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>
+ parameter can automatically creates a dedicated conflict log
table. This table is created in the dedicated
+ <literal>pg_conflict</literal> namespace. The name of the
conflict log table
+ is <literal>pg_conflict_<subid></literal>. The predefined
schema of this table is
+ detailed in
+ <xref linkend="logical-replication-conflict-log-schema"/>.
+ </para>
+
2a.
Typo: /can automatically creates/can automatically create/
~
2b.
It sounds a bit strange to say "a dedicated" 2x in 2 sentences. Maybe
omit the first one:
SUGGESTION
... can automatically create a conflict log table.
~
2c.
Perhaps the "conflict log table" should be using <firstterm> SGML markup here.
~~~
3.
+ <para>
+ The conflicting row data, including the incoming remote row
(<literal>remote_tuple</literal>)
+ and the associated local conflict details
(<literal>local_conflicts</literal>), is stored in
+ <type>JSON</type> formats, for flexible querying and analysis.
+ </para>
/formats, for/format for/
~~~
4.
+ <para>
+ If <link linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>
+ is set to log conflicts to the server log, the following format is used:
I'm not sure you need that link because the same parameter was already
linked a bit earlier on this same page.
~~~
(29.9 Restrictions)
5.
+ <listitem>
+ <para>
+ Conflict log tables (see <link
linkend="sql-createsubscription-params-with-conflict-log-destination"><literal>conflict_log_destination</literal></link>
parameter)
+ are never published, even when using FOR ALL TABLES in a publication.
+ </para>
+ </listitem>
That FOR ALL TABLES ought to have <literal> SGML markup.
======
doc/src/sgml/ref/create_subscription.sgml
6.
I still feel that somewhere in here, there ought to be some link/s
back to the "29.8 Conflicts" details about the CLT schema and LOG
formats.
SUGGESTION
conflict_log_destination / log: ... See <link> for the format used to
log conflict details.
conflict_log_destination / table: ... See <link> for the predefined
schema of the conflict log table.
======
Kind Regards,
Peter Smith.
Fujitsu Australia
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-28 12:03 shveta malik <shveta.malik@gmail.com>
parent: Dilip Kumar <dilipbalaut@gmail.com>
1 sibling, 2 replies; 912+ messages in thread
From: shveta malik @ 2026-01-28 12:03 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Peter Smith <smithpb2250@gmail.com>; vignesh C <vignesh21@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Tue, Jan 27, 2026 at 9:24 AM Dilip Kumar <dilipbalaut@gmail.com> wrote:
>
>
> Also fixed all pending doc comments.
>
Thanks for the patch, Please find a few comments:
patch0001:
1)
+/*
+ * GetLogDestination
+ *
+ * Convert string to enum by comparing against standardized labels.
+ */
+ConflictLogDest
+GetLogDestination(const char *dest)
IMO the name of the function should be GetConflictLogDest, otherwise
the current name might suggest it applies to all the logs related to
subscription, which is misleading.
2)
+ { .attname = "schemaname", .atttypid = TEXTOID },
I checked the catalog tables known to me which refer to namespace, all
use namespace or nsp in their names, none use 'schema'. Examples:
pg_namespace, pg_class, pg_type. pg_constraint. Shall we use nspname
instead?
<No issues found in my local testing for 0001>
~~
patch0002:
1)
conflict.c compiles without these:
+#include "utils/fmgroids.h"
+#include "utils/json.h"
2)
+ /*
+ * Get the conflict log destination. Also, (if there is one) return the
+ * CLT relation already opened and ready for insertion.
+ */
+ conflictlogrel = GetConflictLogDestAndTable(&dest);
'CLT relation' means conflict log table relation. Shall we correct it
to mention either table alone or relation alone?
3)
This is defined in conflict.h:
+/* The single source of truth for the conflict log table schema */
+static const ConflictLogColumnDef ConflictLogSchema[] =
+{
....
+ { .attname = "local_conflicts", .atttypid = JSONARRAYOID }
+};
while its element 'local_conflicts' is defined in conflict.c:
+/* Schema for the elements within the 'local_conflicts' JSON array */
+static const ConflictLogColumnDef LocalConflictSchema[] =
+{
...
+};
It takes some time to figure this part out as a reader of code. I
think we shall define LocalConflictSchema schema immediately after
ConflictLogSchema for anyone to understand it better, unless there is
something blocking it?
4)
+# Verify that '2' is present inside the JSON structure using a regex
+# This matches the key/value pattern for "a": 2
+like($raw_json, qr/\\"a\\":2/, 'Verified that key 2 exists in the
local_conflicts');
+
Reviewing further. Testing of 0002 yet to be finished.
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-28 12:06 shveta malik <shveta.malik@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
1 sibling, 1 reply; 912+ messages in thread
From: shveta malik @ 2026-01-28 12:06 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Peter Smith <smithpb2250@gmail.com>; vignesh C <vignesh21@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Wed, Jan 28, 2026 at 5:33 PM shveta malik <shveta.malik@gmail.com> wrote:
>
>
> 4)
> +# Verify that '2' is present inside the JSON structure using a regex
> +# This matches the key/value pattern for "a": 2
> +like($raw_json, qr/\\"a\\":2/, 'Verified that key 2 exists in the
> local_conflicts');
> +
Oops, I missed adding my feedback for pt4 earlier, here it is:
To properly validate correctness, given that local_conflicts is an
array of multiple local tuple details here, we should also check that
it contains the keys b:3 and c:4.
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-28 20:59 Peter Smith <smithpb2250@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
1 sibling, 0 replies; 912+ messages in thread
From: Peter Smith @ 2026-01-28 20:59 UTC (permalink / raw)
To: shveta malik <shveta.malik@gmail.com>; +Cc: Dilip Kumar <dilipbalaut@gmail.com>; vignesh C <vignesh21@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Wed, Jan 28, 2026 at 11:04 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> patch0002:
>
...
>
> 3)
> This is defined in conflict.h:
>
> +/* The single source of truth for the conflict log table schema */
> +static const ConflictLogColumnDef ConflictLogSchema[] =
> +{
> ....
> + { .attname = "local_conflicts", .atttypid = JSONARRAYOID }
> +};
>
> while its element 'local_conflicts' is defined in conflict.c:
>
> +/* Schema for the elements within the 'local_conflicts' JSON array */
> +static const ConflictLogColumnDef LocalConflictSchema[] =
> +{
> ...
> +};
>
> It takes some time to figure this part out as a reader of code. I
> think we shall define LocalConflictSchema schema immediately after
> ConflictLogSchema for anyone to understand it better, unless there is
> something blocking it?
>
+1.
I had asked for this same change last year [1 - #17]
======
[1] https://www.postgresql.org/message-id/CAHut%2BPtSggpJH36YOwdfmY5gU6yr7Wa-%3Dreht4c2v%2Bn8FYUKJg%40ma...
Kind Regards,
Peter Smith.
Fujitsu Australia
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-29 05:33 shveta malik <shveta.malik@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: shveta malik @ 2026-01-29 05:33 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Peter Smith <smithpb2250@gmail.com>; vignesh C <vignesh21@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
On Wed, Jan 28, 2026 at 5:36 PM shveta malik <shveta.malik@gmail.com> wrote:
>
> On Wed, Jan 28, 2026 at 5:33 PM shveta malik <shveta.malik@gmail.com> wrote:
> >
> >
> > 4)
> > +# Verify that '2' is present inside the JSON structure using a regex
> > +# This matches the key/value pattern for "a": 2
> > +like($raw_json, qr/\\"a\\":2/, 'Verified that key 2 exists in the
> > local_conflicts');
> > +
>
> Oops, I missed adding my feedback for pt4 earlier, here it is:
>
> To properly validate correctness, given that local_conflicts is an
> array of multiple local tuple details here, we should also check that
> it contains the keys b:3 and c:4.
>
Few more comments on 002:
5)
+ bool log_dest_clt;
+ bool log_dest_logfile;
Do we need 'clt' in the variable name? It would be clearer to name it
log_dest_table. Having said that, neither of these names clearly
indicate a boolean value. Will it be better to name these as
logToTable and logToLogFile?
6)
+/*
+ * Helper function to extract the "raw" index key Datums and their null flags
+ * from a TupleTableSlot, given an already open index descriptor.
+ * This is the reusable core logic.
+ */
+static void
+build_index_datums_from_slot(EState *estate, Relation localrel,
+ TupleTableSlot *slot,
+ Relation indexDesc, Datum *values,
+ bool *isnull)
We should mention which ones are output variables here. Something like:
Helper function to extract...to values[] and isnull[] arrays.
<or any other way>
7)
In build_index_datums_from_slot(), shall we check/assert that 'values'
and 'isnull' are non-null before passing it to FormIndexDatum.
8)
+static TupleDesc
+build_conflict_tupledesc(void)
The name gives an impression that we are building a full
tuple-descriptor for CLT, while it is only for local_conflicts. Shall
we name it to build_tupledesc_for_local_conflicts or
build_local_conflicts_tupledesc. It aligns with its caller as well
'build_local_conflicts_json_array'.
9)
build_local_conflicts_json_array():
+ int i;
+ i = 0;
+ foreach(lc, json_datums)
+ {
+ json_datum_array[i] = (Datum) lfirst(lc);
+ i++;
+ }
I think variable 'i' and its usage is leftover from some debugging.
10)
There are few new functions such as tuple_table_slot_to_json_datum,
tuple_table_slot_to_indextup_json, build_conflict_tupledesc,
prepare_conflict_log_tuple which mention function name in
header-comment section as well, while other functions do not. We can
keep header-comment section of all functions in the same format. I
think we generally do not mention function names explicitly in the
header.
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-29 06:07 shveta malik <shveta.malik@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 1 reply; 912+ messages in thread
From: shveta malik @ 2026-01-29 06:07 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Peter Smith <smithpb2250@gmail.com>; vignesh C <vignesh21@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
Few comments on 0003:
1)
Section '29.2. Subscription' has this:
~~
Conflicts that occur during replication are, by default, logged as
plain text in the server log, which can make automated monitoring and
analysis difficult. The CREATE SUBSCRIPTION command provides the
conflict_log_destination option to record detailed conflict
information in a structured, queryable format. When this parameter is
set to table or all, the system automatically manages a dedicated
conflict log table, which is created and dropped along with the
subscription. This significantly improves post-mortem analysis and
operational visibility of the replication setup.
~~
This new section begins by discussing log-destination without
providing much background on conflicts, which makes it feel somewhat
out of place. It could be made more general by first referring the
reader to the 'Conflicts' page. How about this:
Conflicts can occur during logical replication when changes from the
publisher cannot be applied on the subscriber, for example due to
constraint violations or concurrent data modifications. An overview of
possible conflict types is provided in Section 29.4, Logical
Replication Conflicts. By default, conflict information is written to
the server log. The CREATE SUBSCRIPTION command provides the
conflict_log_destination parameter, which allows conflict details to
be recorded in a dedicated table, making post-mortem analysis and
operational monitoring easier.
2)
'29.8. Conflicts' section has this:
~~
Note that there are other conflict scenarios, such as exclusion
constraint violations. Currently, we do not provide additional details
for them in the log.
The conflict_log_destination parameter can automatically creates a
dedicated conflict log table. This table is created in the dedicated
pg_conflict namespace. The name of the conflict log table is
pg_conflict_<subid>. The predefined schema of this table is detailed
in Table 29.3.
~~
This phrasing feels somewhat abrupt and lacks context. A clearer
version could be:
By default, conflict information is written to the server log. The
CREATE SUBSCRIPTION command also provides the conflict_log_destination
parameter to record detailed conflict information in a structured,
queryable format. When this parameter is set to table or all, the
system automatically
manages a dedicated conflict log table, which is created and dropped
along with the subscription. The table is created in the pg_conflict
schema and is named pg_conflict_<subid>. The schema of this table is
described in Table 29.3.
3)
'Restrictions' section has this:
Conflict log tables (see conflict_log_destination parameter) are never
published, even when using FOR ALL TABLES in a publication.
We shall also mention the restriction of publishing 'for tables in
schema pg_conflict'.
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* Re: Proposal: Conflict log history table for Logical Replication
@ 2026-01-29 11:16 shveta malik <shveta.malik@gmail.com>
parent: shveta malik <shveta.malik@gmail.com>
0 siblings, 0 replies; 912+ messages in thread
From: shveta malik @ 2026-01-29 11:16 UTC (permalink / raw)
To: Dilip Kumar <dilipbalaut@gmail.com>; +Cc: Peter Smith <smithpb2250@gmail.com>; vignesh C <vignesh21@gmail.com>; Amit Kapila <amit.kapila16@gmail.com>; Masahiko Sawada <sawada.mshk@gmail.com>; Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; shveta malik <shveta.malik@gmail.com>
Dilip, I was testing patch002, few observations:
1)
remote_commit_ts is '2026-01-29 14:38:02.777599+05:30' while
local_conflicts.commit_ts is of format
'2026-01-29T14:37:35.805819+05:30' i.e. a 'T' to separate date and
time in local-commit_ts case. IIUC, this is the expected (standard)
way for timestamps to be in json-format. Can you please confirm?
2)
Differences between LOG and TABLE:
a) In LOG, the column names are not included in the remote or local
tuples, whereas in the TABLE, they are. For example:
LOG: Could not apply remote change: remote row (2, 3, 4)
TABLE: remote_tuple: {"a":2,"b":3,"c":4}
b) remote_commit_ts is not recorded in LOG, but it is included in the TABLE.
IMO, both of these differences are okay as TABLE is expected to store
(or can store) more detailed information. I’m just noting these here
for others’ input. Let me know your thoughts.
3)
If we look at the table overall, the name 'local_conflicts' can be a
bit misleading. This column actually stores the details about local
rows involved in a conflict, rather than describing the conflict
itself. See example below. A clearer name might be local_data,
local_tuples_data, or local_tuples_detail. Thoughts?
local_conflicts for one update_exists:
{"{\"xid\":\"791\",\"commit_ts\":\"2026-01-29T14:37:35.805819+05:30\",\"origin\":null,\"key\":{\"i\":40},\"tuple\":{\"i\":40,\"j\":10}}"}
~~
patch002 works well in my testing done so far.
thanks
Shveta
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
* [PATCH v3 1/7] Track retired system procedures in pg_retired.dat
@ 2026-01-29 23:47 Mark Wong <mark.wong@enterprisedb.com>
0 siblings, 0 replies; 912+ messages in thread
From: Mark Wong @ 2026-01-29 23:47 UTC (permalink / raw)
System procedures may be removed from time to time. Keep track of
previously used OIDs to prevent potential conflicts from reusing OIDs
after an upgrade.
---
src/include/catalog/pg_retired.dat | 20 ++++++++++++++++++++
src/include/catalog/renumber_oids.pl | 9 +++++++++
src/include/catalog/unused_oids | 7 +++++++
3 files changed, 36 insertions(+)
create mode 100644 src/include/catalog/pg_retired.dat
diff --git a/src/include/catalog/pg_retired.dat b/src/include/catalog/pg_retired.dat
new file mode 100644
index 00000000000..413b68ad1cd
--- /dev/null
+++ b/src/include/catalog/pg_retired.dat
@@ -0,0 +1,20 @@
+#----------------------------------------------------------------------
+#
+# pg_retired.dat
+# Entries that have been removed from pg_proc.dat with OIDs that are
+# are not to be reused.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/include/catalog/pg_retired.dat
+#
+#----------------------------------------------------------------------
+
+[
+
+# What's primarily important here is knowing which OIDs are not to be reused.
+# At the same time, it may be good to be reminded what procedure was associated
+# with it.
+
+]
diff --git a/src/include/catalog/renumber_oids.pl b/src/include/catalog/renumber_oids.pl
index f6e25d0f1e1..7489be02a50 100755
--- a/src/include/catalog/renumber_oids.pl
+++ b/src/include/catalog/renumber_oids.pl
@@ -68,6 +68,15 @@ my $oids = Catalog::FindAllOidsFromHeaders(@header_files);
my %oidhash;
@oidhash{@$oids} = undef;
+# Also include OIDs listed in pg_retired.dat to prevent new OIDs to be mapped
+# to OIDs that have been retired.
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ $oidhash{ $row->{oid} } = undef
+ if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Select new OIDs for existing OIDs in the mapped range.
# We do this first so that we preserve the ordering of the mapped OIDs
# (for reproducibility's sake), and so that if we fail due to running out
diff --git a/src/include/catalog/unused_oids b/src/include/catalog/unused_oids
index 147fba5e209..aeb485d9b5e 100755
--- a/src/include/catalog/unused_oids
+++ b/src/include/catalog/unused_oids
@@ -32,6 +32,13 @@ my @input_files = glob("pg_*.h");
my $oids = Catalog::FindAllOidsFromHeaders(@input_files);
+# Also exclude OIDs listed in pg_retired.dat
+my $retired_data = Catalog::ParseData('pg_retired.dat', undef, 0);
+foreach my $row (@$retired_data)
+{
+ push @{$oids}, $row->{oid} if ref $row eq 'HASH' && defined $row->{oid};
+}
+
# Also push FirstGenbkiObjectId to serve as a terminator for the last gap.
my $FirstGenbkiObjectId =
Catalog::FindDefinedSymbol('access/transam.h', '..', 'FirstGenbkiObjectId');
--
2.43.0
--zd8Z8rlPOcTi77n/
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
filename="v3-0002-Handle-pg_get_ruledef-default-args-in-system_func.patch"
^ permalink raw reply [nested|flat] 912+ messages in thread
end of thread, other threads:[~2026-01-29 23:47 UTC | newest]
Thread overview: 912+ messages (download: mbox mbox.gz follow: Atom feed)
-- links below jump to the message on this page --
2019-07-05 14:24 [PATCH 16/17] Teach the remaining frontend application about encryption. Antonin Houska <ah@cybertec.at>
2025-08-05 12:24 Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-08-07 06:55 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-08-07 08:13 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-08-07 09:38 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-08-08 03:28 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-08-08 04:31 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-08-08 08:42 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-08-13 10:08 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-08-14 10:56 ` Re: Proposal: Conflict log history table for Logical Replication Alastair Turner <minion@decodable.me>
2025-08-15 06:52 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-08-15 09:00 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-08-18 06:55 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-08-20 06:16 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-08-20 12:16 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-08-21 03:47 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-09-06 09:38 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-09-07 08:12 ` Re: Proposal: Conflict log history table for Logical Replication Alastair Turner <minion@decodable.me>
2025-09-08 06:31 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-09-10 09:55 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-09-10 10:15 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-09-10 11:01 ` Re: Proposal: Conflict log history table for Logical Replication Alastair Turner <minion@decodable.me>
2025-09-10 12:06 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-09-10 19:23 ` Re: Proposal: Conflict log history table for Logical Replication Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>
2025-09-11 03:13 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-09-12 10:13 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-09-13 00:45 ` Re: Proposal: Conflict log history table for Logical Replication Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>
2025-09-14 06:53 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-09-18 08:33 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-09-18 13:26 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-09-18 18:15 ` Re: Proposal: Conflict log history table for Logical Replication Masahiko Sawada <sawada.mshk@gmail.com>
2025-09-20 11:59 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-09-23 17:59 ` Re: Proposal: Conflict log history table for Logical Replication Masahiko Sawada <sawada.mshk@gmail.com>
2025-09-24 10:30 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-09-24 11:40 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-09-24 18:35 ` Re: Proposal: Conflict log history table for Logical Replication Masahiko Sawada <sawada.mshk@gmail.com>
2025-09-24 11:35 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-09-25 05:39 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-09-25 06:23 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-09-25 10:49 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-09-26 11:12 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-09-27 15:23 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-09-27 15:54 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-09-27 21:13 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-09-28 11:45 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-09-29 09:57 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-11-11 10:18 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-11-11 10:26 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-11-12 06:50 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-11-12 09:10 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-11-12 09:44 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-11-13 09:09 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-11-13 15:47 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-11-17 06:24 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-11-18 11:17 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-11-19 10:16 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-11-19 11:19 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-12-02 05:15 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-11-19 01:30 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2025-11-20 12:08 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-11-21 02:05 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2025-11-24 06:21 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-11-25 03:33 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2025-11-25 08:29 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-11-25 10:36 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-11-25 14:34 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-11-26 08:35 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-11-26 10:45 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-11-26 11:18 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-11-27 00:59 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2025-11-27 12:20 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-11-28 00:35 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2025-12-01 10:52 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-11-28 06:54 ` Re: Proposal: Conflict log history table for Logical Replication vignesh C <vignesh21@gmail.com>
2025-12-01 08:18 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-11-28 09:01 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-12-01 08:23 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-02 05:07 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-11-19 06:29 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2025-11-19 07:10 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2025-11-18 10:09 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-11-28 12:20 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-12-01 04:41 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-02 06:08 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-12-02 06:36 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-02 07:08 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-02 09:17 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-12-02 11:15 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-02 15:10 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-03 04:19 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-12-03 11:26 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-04 02:00 ` Re: Proposal: Conflict log history table for Logical Replication Masahiko Sawada <sawada.mshk@gmail.com>
2025-12-04 05:18 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-04 10:50 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-12-05 09:55 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-12-05 10:13 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-12-06 04:50 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-08 04:55 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-08 09:08 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-12-08 09:30 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-08 09:51 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-12-08 11:45 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-09 04:42 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-12-09 06:36 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-09 15:11 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-11 11:34 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-12-11 11:40 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-11 12:26 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-12-11 14:19 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-12 03:49 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-12-12 04:11 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-12 04:32 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-12-12 04:58 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-12 05:57 ` Re: Proposal: Conflict log history table for Logical Replication vignesh C <vignesh21@gmail.com>
2025-12-12 09:33 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-12-12 10:03 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-12-14 15:50 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-15 08:45 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-12-15 09:25 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-15 11:41 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-16 04:21 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-12-18 09:09 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-18 09:55 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-18 11:06 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-12-18 23:07 ` Re: Proposal: Conflict log history table for Logical Replication Masahiko Sawada <sawada.mshk@gmail.com>
2025-12-19 04:09 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-12-19 04:23 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-19 05:10 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-12-19 06:19 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-12-24 11:59 ` Re: Proposal: Conflict log history table for Logical Replication vignesh C <vignesh21@gmail.com>
2025-12-19 06:24 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-19 08:27 ` Re: Proposal: Conflict log history table for Logical Replication Masahiko Sawada <sawada.mshk@gmail.com>
2025-12-19 04:52 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-12-19 00:04 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2025-12-19 04:25 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-19 05:42 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2025-12-19 06:14 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-19 06:22 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-12-19 00:28 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2025-12-16 07:17 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-12-14 10:21 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-14 15:46 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-15 09:48 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-12-15 10:31 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-15 11:15 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-12-16 05:03 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-12-17 04:28 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-17 09:44 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-12-17 09:58 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-17 10:19 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-12-16 01:10 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2025-12-16 04:24 ` Re: Proposal: Conflict log history table for Logical Replication vignesh C <vignesh21@gmail.com>
2025-12-20 09:47 ` Re: Proposal: Conflict log history table for Logical Replication vignesh C <vignesh21@gmail.com>
2025-12-20 11:20 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-21 15:46 ` Re: Proposal: Conflict log history table for Logical Replication vignesh C <vignesh21@gmail.com>
2025-12-22 06:48 ` Re: Proposal: Conflict log history table for Logical Replication vignesh C <vignesh21@gmail.com>
2025-12-22 09:39 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-12-22 15:41 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-23 05:25 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-12-23 06:11 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-23 11:48 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-12-23 12:22 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-24 10:32 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-12-25 07:40 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-26 15:27 ` Re: Proposal: Conflict log history table for Logical Replication vignesh C <vignesh21@gmail.com>
2026-01-02 06:36 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2026-01-05 04:28 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2026-01-05 15:02 ` Re: Proposal: Conflict log history table for Logical Replication vignesh C <vignesh21@gmail.com>
2026-01-06 03:21 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2026-01-05 15:00 ` Re: Proposal: Conflict log history table for Logical Replication vignesh C <vignesh21@gmail.com>
2026-01-06 23:43 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2026-01-07 02:44 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2026-01-07 06:42 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2026-01-07 10:56 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2026-01-07 12:01 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2026-01-08 06:59 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2026-01-08 09:21 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2026-01-09 11:18 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2026-01-12 06:34 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2026-01-09 03:00 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2026-01-10 06:49 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2026-01-10 13:15 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2026-01-12 05:26 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2026-01-12 14:32 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2026-01-12 05:29 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2026-01-19 06:08 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2026-01-12 07:09 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2026-01-12 21:54 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2026-01-13 10:28 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2026-01-14 02:05 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2026-01-14 02:09 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2026-01-14 02:17 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2026-01-13 10:28 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2026-01-13 10:39 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2026-01-13 11:32 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2026-01-13 12:45 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2026-01-14 10:29 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2026-01-14 11:39 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2026-01-16 06:32 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2026-01-19 02:27 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2026-01-19 04:18 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2026-01-19 03:22 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2026-01-19 04:11 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2026-01-19 05:26 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2026-01-19 07:02 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2026-01-20 06:29 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2026-01-20 21:20 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2026-01-20 13:18 ` Re: Proposal: Conflict log history table for Logical Replication vignesh C <vignesh21@gmail.com>
2026-01-23 10:54 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2026-01-27 03:08 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2026-01-27 03:53 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2026-01-28 04:13 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2026-01-28 12:03 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2026-01-28 12:06 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2026-01-29 05:33 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2026-01-29 06:07 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2026-01-29 11:16 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2026-01-28 20:59 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2026-01-20 10:38 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-12-29 06:02 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2026-01-04 12:21 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2026-01-05 11:18 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2026-01-05 11:40 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2026-01-01 05:56 ` Re: Proposal: Conflict log history table for Logical Replication vignesh C <vignesh21@gmail.com>
2026-01-01 07:02 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2026-01-01 10:31 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2026-01-05 06:41 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2026-01-05 09:43 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2026-01-09 07:12 ` Re: Proposal: Conflict log history table for Logical Replication vignesh C <vignesh21@gmail.com>
2026-01-09 13:42 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2026-01-12 02:21 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2026-01-12 11:48 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2026-01-19 04:52 ` Re: Proposal: Conflict log history table for Logical Replication vignesh C <vignesh21@gmail.com>
2026-01-01 11:04 ` Re: Proposal: Conflict log history table for Logical Replication vignesh C <vignesh21@gmail.com>
2026-01-01 11:05 ` Re: Proposal: Conflict log history table for Logical Replication vignesh C <vignesh21@gmail.com>
2026-01-02 04:21 ` Re: Proposal: Conflict log history table for Logical Replication vignesh C <vignesh21@gmail.com>
2026-01-02 12:23 ` Re: Proposal: Conflict log history table for Logical Replication vignesh C <vignesh21@gmail.com>
2025-12-22 10:24 ` Re: Proposal: Conflict log history table for Logical Replication vignesh C <vignesh21@gmail.com>
2025-12-22 10:34 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-12-22 10:31 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-23 06:49 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2025-12-24 07:41 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2025-12-23 10:03 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2026-01-05 06:12 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-23 06:15 ` Re: Proposal: Conflict log history table for Logical Replication vignesh C <vignesh21@gmail.com>
2025-12-16 05:41 ` Re: Proposal: Conflict log history table for Logical Replication vignesh C <vignesh21@gmail.com>
2025-12-04 06:51 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-12-04 07:01 ` Re: Proposal: Conflict log history table for Logical Replication Peter Smith <smithpb2250@gmail.com>
2025-12-04 14:35 ` Re: Proposal: Conflict log history table for Logical Replication vignesh C <vignesh21@gmail.com>
2025-12-05 05:09 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-06 15:06 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-08 03:42 ` Re: Proposal: Conflict log history table for Logical Replication vignesh C <vignesh21@gmail.com>
2025-12-08 04:06 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-05 03:54 ` Re: Proposal: Conflict log history table for Logical Replication vignesh C <vignesh21@gmail.com>
2025-12-05 05:16 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-05 09:29 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-12-01 08:27 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-12-01 08:34 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-12-01 09:27 ` Re: Proposal: Conflict log history table for Logical Replication shveta malik <shveta.malik@gmail.com>
2025-12-01 09:41 ` Re: Proposal: Conflict log history table for Logical Replication Amit Kapila <amit.kapila16@gmail.com>
2025-12-01 10:32 ` Re: Proposal: Conflict log history table for Logical Replication Dilip Kumar <dilipbalaut@gmail.com>
2025-09-13 00:44 ` Re: Proposal: Conflict log history table for Logical Replication Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
2026-01-29 23:47 [PATCH v3 1/7] Track retired system procedures in pg_retired.dat Mark Wong <mark.wong@enterprisedb.com>
This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox