public inbox for [email protected]  
help / color / mirror / Atom feed
[PATCH 08/10] Default to zstd..
63+ messages / 4 participants
[nested] [flat]

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--jozmn01XJZjDjM3N
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--XsQoSWH+UP9D9v3l
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 20c6e08c02..dffe70208b 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index c4956745ce..780c0e785f 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 9bac79b579..5a8447a525 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--0qVF/w3MHQqLSynd
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--jozmn01XJZjDjM3N
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--XsQoSWH+UP9D9v3l
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--XsQoSWH+UP9D9v3l
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 20c6e08c02..dffe70208b 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index c4956745ce..780c0e785f 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 9bac79b579..5a8447a525 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--0qVF/w3MHQqLSynd
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--XsQoSWH+UP9D9v3l
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 20c6e08c02..dffe70208b 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index c4956745ce..780c0e785f 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 9bac79b579..5a8447a525 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--0qVF/w3MHQqLSynd
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--XsQoSWH+UP9D9v3l
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 20c6e08c02..dffe70208b 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index c4956745ce..780c0e785f 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 9bac79b579..5a8447a525 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--0qVF/w3MHQqLSynd
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--jozmn01XJZjDjM3N
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--XsQoSWH+UP9D9v3l
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 20c6e08c02..dffe70208b 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index c4956745ce..780c0e785f 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 9bac79b579..5a8447a525 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--0qVF/w3MHQqLSynd
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--XsQoSWH+UP9D9v3l
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 20c6e08c02..dffe70208b 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index c4956745ce..780c0e785f 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 9bac79b579..5a8447a525 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--0qVF/w3MHQqLSynd
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--jozmn01XJZjDjM3N
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 20c6e08c02..dffe70208b 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index c4956745ce..780c0e785f 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 9bac79b579..5a8447a525 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--0qVF/w3MHQqLSynd
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--XsQoSWH+UP9D9v3l
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 20c6e08c02..dffe70208b 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index c4956745ce..780c0e785f 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 9bac79b579..5a8447a525 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--0qVF/w3MHQqLSynd
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--jozmn01XJZjDjM3N
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--XsQoSWH+UP9D9v3l
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--jozmn01XJZjDjM3N
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 20c6e08c02..dffe70208b 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index c4956745ce..780c0e785f 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 9bac79b579..5a8447a525 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--0qVF/w3MHQqLSynd
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--jozmn01XJZjDjM3N
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--XsQoSWH+UP9D9v3l
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--XsQoSWH+UP9D9v3l
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--jozmn01XJZjDjM3N
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 20c6e08c02..dffe70208b 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index c4956745ce..780c0e785f 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 9bac79b579..5a8447a525 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--0qVF/w3MHQqLSynd
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--jozmn01XJZjDjM3N
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--XsQoSWH+UP9D9v3l
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH v9 8/9] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 72bbd719dc..b445db933e 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8750,7 +8750,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index c348a3ee91..f8ee35ebfd 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1015,7 +1015,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index f2b0af6360..599381337e 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -98,7 +98,7 @@ char	   *XLogArchiveCommand = NULL;
 bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
-int		wal_compression = WAL_COMPRESSION_LZ4;
+int		wal_compression = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 0ad62e4d1f..f37251a27f 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4822,7 +4822,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--neYutvxvOLaeuPCA
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="v9-0009-Use-GUC-hooks-to-support-compression-level.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--jozmn01XJZjDjM3N
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 20c6e08c02..dffe70208b 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index c4956745ce..780c0e785f 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 9bac79b579..5a8447a525 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--0qVF/w3MHQqLSynd
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--XsQoSWH+UP9D9v3l
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--jozmn01XJZjDjM3N
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--XsQoSWH+UP9D9v3l
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 20c6e08c02..dffe70208b 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index c4956745ce..780c0e785f 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 9bac79b579..5a8447a525 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--0qVF/w3MHQqLSynd
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--XsQoSWH+UP9D9v3l
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 20c6e08c02..dffe70208b 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index c4956745ce..780c0e785f 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 9bac79b579..5a8447a525 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--0qVF/w3MHQqLSynd
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH v7 8/9] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 72bbd719dc..b445db933e 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8750,7 +8750,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index c348a3ee91..f8ee35ebfd 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1015,7 +1015,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 3ddfa8c0ee..33aa35e783 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index da1ed32943..dab7084fea 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4832,7 +4832,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--JYK4vJDZwFMowpUq--





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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--jozmn01XJZjDjM3N
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 3/6] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 .cirrus.yml                          | 14 ++++++++++++++
 configure                            |  6 ++++--
 configure.ac                         |  2 +-
 src/backend/access/transam/xlog.c    |  2 +-
 src/backend/utils/misc/guc.c         |  2 +-
 src/tools/ci/windows_build_config.pl |  1 +
 6 files changed, 22 insertions(+), 5 deletions(-)

diff --git a/.cirrus.yml b/.cirrus.yml
index d10b0a82f9..4735c67608 100644
--- a/.cirrus.yml
+++ b/.cirrus.yml
@@ -77,6 +77,7 @@ task:
     mkdir -m 770 /tmp/cores
     chown root:postgres /tmp/cores
     sysctl kern.corefile='/tmp/cores/%N.%P.core'
+    pkg install -y zstd
 
   # NB: Intentionally build without --with-llvm. The freebsd image size is
   # already large enough to make VM startup slow, and even without llvm
@@ -184,6 +185,8 @@ task:
     mkdir -m 770 /tmp/cores
     chown root:postgres /tmp/cores
     sysctl kernel.core_pattern='/tmp/cores/%e-%s-%p.core'
+    apt-get update
+    apt-get -y install libzstd-dev
 
   configure_script: |
     su postgres <<-EOF
@@ -265,6 +268,7 @@ task:
       openldap \
       openssl \
       python \
+      zstd \
       tcl-tk
 
     brew cleanup -s # to reduce cache size
@@ -385,6 +389,12 @@ task:
     set
 
   configure_script:
+    # XXX: not working
+    - choco install -y cmake
+    - curl -L -o zstd.zip https://github.com/facebook/zstd/archive/refs/tags/v1.5.2.zip
+    - unzip zstd.zip
+    - cd zstd-1.5.2 && vcvarsall x64 && build\VS_scripts\build.VS2017.cmd && cd ..
+    #- cp zstd-1.5.2/build/VS_scripts/build.VS2017.cmd
     # copy errors out when using forward slashes
     - copy src\tools\ci\windows_build_config.pl src\tools\msvc\config.pl
     - vcvarsall x64
@@ -467,6 +477,10 @@ task:
     image: $CONTAINER_REPO/linux_debian_bullseye_ci:latest
     cpu: $CPUS
 
+  setup_os_script: |
+    apt-get update
+    apt-get -y install libzstd-dev
+
   sysinfo_script: |
     id
     uname -a
diff --git a/configure b/configure
index f3cb5c2b51..b88917fcb5 100755
--- a/configure
+++ b/configure
@@ -1584,7 +1584,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --with-lz4              build with LZ4 support
-  --with-zstd             build with ZSTD support
+  --without-zstd          build without Zstd support
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -9070,7 +9070,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index 19d1a80367..4ae681d8bf 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1060,7 +1060,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with ZSTD support])
-PGAC_ARG_BOOL(with, zstd, no, [build with ZSTD support],
+PGAC_ARG_BOOL(with, zstd, yes, [build with ZSTD support],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with ZSTD support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 0d2bd7a357..9aa4d3eaea 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -120,7 +120,7 @@ char	   *XLogArchiveCommand = NULL;
 bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
-int			wal_compression = WAL_COMPRESSION_NONE;
+int			wal_compression = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 66e6d664b0..96854b6a2b 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4900,7 +4900,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression,
-		WAL_COMPRESSION_NONE, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
diff --git a/src/tools/ci/windows_build_config.pl b/src/tools/ci/windows_build_config.pl
index b0d4360c74..97ce79ea7f 100644
--- a/src/tools/ci/windows_build_config.pl
+++ b/src/tools/ci/windows_build_config.pl
@@ -9,5 +9,6 @@ $config->{"asserts"} = 1;
 $config->{"openssl"} = "c:/openssl/1.1/";
 $config->{"perl"} = "c:/strawberry/$ENV{DEFAULT_PERL_VERSION}/perl/";
 $config->{"python"} = "c:/python/";
+$config->{"zstd"} = "c:/zstd/";
 
 1;
-- 
2.17.1


--pAwQNkOnpTn9IO2O
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0004-Use-GUC-hooks-to-support-compression-level.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 20c6e08c02..dffe70208b 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index c4956745ce..780c0e785f 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 9bac79b579..5a8447a525 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--0qVF/w3MHQqLSynd
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 20c6e08c02..dffe70208b 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index c4956745ce..780c0e785f 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 9bac79b579..5a8447a525 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--0qVF/w3MHQqLSynd
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--jozmn01XJZjDjM3N
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--jozmn01XJZjDjM3N
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 20c6e08c02..dffe70208b 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index c4956745ce..780c0e785f 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 9bac79b579..5a8447a525 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--0qVF/w3MHQqLSynd
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--jozmn01XJZjDjM3N
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--jozmn01XJZjDjM3N
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--XsQoSWH+UP9D9v3l
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--XsQoSWH+UP9D9v3l
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--jozmn01XJZjDjM3N
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--jozmn01XJZjDjM3N
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 20c6e08c02..dffe70208b 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index c4956745ce..780c0e785f 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 9bac79b579..5a8447a525 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--0qVF/w3MHQqLSynd
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 81e23418b2..253f028fc4 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index d6f6349067..8d72710fa7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 307eee6626..92023de9f5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--XsQoSWH+UP9D9v3l
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* [PATCH 08/10] Default to zstd..
@ 2021-03-12 21:35 Justin Pryzby <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Justin Pryzby @ 2021-03-12 21:35 UTC (permalink / raw)

for CI, not for merge
---
 configure                         | 6 ++++--
 configure.ac                      | 2 +-
 src/backend/access/transam/xlog.c | 2 +-
 src/backend/utils/misc/guc.c      | 2 +-
 4 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/configure b/configure
index 20c6e08c02..dffe70208b 100755
--- a/configure
+++ b/configure
@@ -1582,7 +1582,7 @@ Optional Packages:
                           use system time zone data in DIR
   --without-zlib          do not use Zlib
   --without-lz4           build without LZ4 support
-  --with-zstd             build with Zstd compression library
+  --without-zstd          build without Zstd compression library
   --with-gnu-ld           assume the C compiler uses GNU ld [default=no]
   --with-ssl=LIB          use LIB for SSL/TLS support (openssl)
   --with-openssl          obsolete spelling of --with-ssl=openssl
@@ -8740,7 +8740,9 @@ $as_echo "#define USE_ZSTD 1" >>confdefs.h
   esac
 
 else
-  with_zstd=no
+  with_zstd=yes
+
+$as_echo "#define USE_ZSTD 1" >>confdefs.h
 
 fi
 
diff --git a/configure.ac b/configure.ac
index c4956745ce..780c0e785f 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1005,7 +1005,7 @@ fi
 # ZSTD
 #
 AC_MSG_CHECKING([whether to build with zstd support])
-PGAC_ARG_BOOL(with, zstd, no, [build with Zstd compression library],
+PGAC_ARG_BOOL(with, zstd, yes, [build without Zstd compression library],
               [AC_DEFINE([USE_ZSTD], 1, [Define to 1 to build with zstd support. (--with-zstd)])])
 AC_MSG_RESULT([$with_zstd])
 AC_SUBST(with_zstd)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 9bac79b579..5a8447a525 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -99,7 +99,7 @@ bool		EnableHotStandby = false;
 bool		fullPageWrites = true;
 bool		wal_log_hints = false;
 bool		wal_compression = false;
-int			wal_compression_method = WAL_COMPRESSION_LZ4;
+int			wal_compression_method = WAL_COMPRESSION_ZSTD;
 char	   *wal_consistency_checking_string = NULL;
 bool	   *wal_consistency_checking = NULL;
 bool		wal_init_zero = true;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 52f9cd0242..8031e027aa 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -4728,7 +4728,7 @@ static struct config_enum ConfigureNamesEnum[] =
 			NULL
 		},
 		&wal_compression_method,
-		WAL_COMPRESSION_LZ4, wal_compression_options,
+		WAL_COMPRESSION_ZSTD, wal_compression_options,
 		NULL, NULL, NULL
 	},
 
-- 
2.17.0


--0qVF/w3MHQqLSynd
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
 filename="0009-Add-zstd-compression-levels.patch"



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

* RE: Parallel Apply
@ 2026-01-05 07:40 Hayato Kuroda (Fujitsu) <[email protected]>
  2026-01-30 11:18 ` RE: Parallel Apply Hayato Kuroda (Fujitsu) <[email protected]>
  0 siblings, 1 reply; 63+ messages in thread

From: Hayato Kuroda (Fujitsu) @ 2026-01-05 07:40 UTC (permalink / raw)
  To: Hayato Kuroda (Fujitsu) <[email protected]>; PostgreSQL Hackers <[email protected]>; +Cc: Tomas Vondra <[email protected]>; Amit Kapila <[email protected]>; 'Dilip Kumar' <[email protected]>; 'Andrei Lepikhov' <[email protected]>; Zhijie Hou (Fujitsu) <[email protected]>; 'wenhui qiu' <[email protected]>

Happy new year hackers,

I found that CFbot sometimes failed tests. Per my analysis, there were two
issues in the 0005 patch. The following describes two changes.

1)
Took care of the case where an empty prepared transaction was replicated.
The leader worker would gather even such transactions in get_flush_position()
and try to clean up a replica identity hash. If the empty transaction is firstly
replicated after the worker is launched, however, the replica identity hash is
not yet initialized, which causes the segmentation fault. To address the issue,
a guard was added to the cleanup function.

As far as I know, an empty prepared transaction can happen if
  a) the prepared transaction has already been rolled back while decoding, or
  b) all changes are skipped.
Added test sometimes meets a) due to the timing issue.

2)
Fixed a timing issue in 050_parallel_apply.pl. The test sets the two_phase
option to true, but sometimes it fails if the apply workers are not yet finished
after the subscription is disabled. Now the test ensures there are no apply workers.

Best regards,
Hayato Kuroda
FUJITSU LIMITED



Attachments:

  [application/octet-stream] v7-0001-Introduce-new-type-of-logical-replication-message.patch (6.0K, ../../TY7PR01MB145541DD6F5BE4CD403BA39FDF586A@TY7PR01MB14554.jpnprd01.prod.outlook.com/2-v7-0001-Introduce-new-type-of-logical-replication-message.patch)
  download | inline diff:
From 5ee009136a31e7eb5d35c8c42d05b7c2f8f3f5c5 Mon Sep 17 00:00:00 2001
From: Hayato Kuroda <[email protected]>
Date: Mon, 1 Dec 2025 10:37:27 +0900
Subject: [PATCH v7 1/8] Introduce new type of logical replication messages to
 track dependencies

This patch introduces two logical replication messages,
LOGICAL_REP_MSG_INTERNAL_DEPENDENCY and LOGICAL_REP_MSG_INTERNAL_RELATION.
Apart from other messages, they are not sent by walsnders; the leader worker
sends to parallel workers based on the needs.

LOGICAL_REP_MSG_INTERNAL_DEPENDENCY ensures that dependent transactions are
committed in the correct order. It has a list of transaction IDs that parallel
workers must wait for. The message type would be generated when the leader
detects a dependency between the current and other transactions, or just before
the COMMIT message. The latter one is used to preserve the commit ordering
between the publisher and the subscriber.

LOGICAL_REP_MSG_INTERNAL_RELATION is used to synchronize the relation
information between the leader and parallel workers. It has a list of relations
that the leader already knows, and parallel workers also update the relmap in
response to the message. This type of message is generated when the leader
allocates a new parallel worker to the transaction, or when the publisher sends
additional RELATION messages.

Author: Hou Zhijie <[email protected]>
Author: Hayato Kuroda <[email protected]>
---
 .../replication/logical/applyparallelworker.c | 16 ++++++
 src/backend/replication/logical/proto.c       |  4 ++
 src/backend/replication/logical/worker.c      | 49 +++++++++++++++++++
 src/include/replication/logicalproto.h        |  2 +
 src/include/replication/worker_internal.h     |  4 ++
 5 files changed, 75 insertions(+)

diff --git a/src/backend/replication/logical/applyparallelworker.c b/src/backend/replication/logical/applyparallelworker.c
index a4aafcf5b6e..055feea0bc5 100644
--- a/src/backend/replication/logical/applyparallelworker.c
+++ b/src/backend/replication/logical/applyparallelworker.c
@@ -1645,3 +1645,19 @@ pa_xact_finish(ParallelApplyWorkerInfo *winfo, XLogRecPtr remote_lsn)
 
 	pa_free_worker(winfo);
 }
+
+/*
+ * Wait for the given transaction to finish.
+ */
+void
+pa_wait_for_depended_transaction(TransactionId xid)
+{
+	elog(DEBUG1, "wait for depended xid %u", xid);
+
+	for (;;)
+	{
+		/* XXX wait until given transaction is finished */
+	}
+
+	elog(DEBUG1, "finish waiting for depended xid %u", xid);
+}
diff --git a/src/backend/replication/logical/proto.c b/src/backend/replication/logical/proto.c
index 27ad74fd759..ded46c49a83 100644
--- a/src/backend/replication/logical/proto.c
+++ b/src/backend/replication/logical/proto.c
@@ -1253,6 +1253,10 @@ logicalrep_message_type(LogicalRepMsgType action)
 			return "STREAM ABORT";
 		case LOGICAL_REP_MSG_STREAM_PREPARE:
 			return "STREAM PREPARE";
+		case LOGICAL_REP_MSG_INTERNAL_DEPENDENCY:
+			return "INTERNAL DEPENDENCY";
+		case LOGICAL_REP_MSG_INTERNAL_RELATION:
+			return "INTERNAL RELATION";
 	}
 
 	/*
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 718408bb599..73d38644c4a 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -629,6 +629,47 @@ static TransApplyAction get_transaction_apply_action(TransactionId xid,
 
 static void replorigin_reset(int code, Datum arg);
 
+/*
+ * Handle internal dependency information.
+ *
+ * Wait for all transactions listed in the message to commit.
+ */
+static void
+apply_handle_internal_dependency(StringInfo s)
+{
+	int			nxids = pq_getmsgint(s, 4);
+
+	for (int i = 0; i < nxids; i++)
+	{
+		TransactionId xid = pq_getmsgint(s, 4);
+
+		pa_wait_for_depended_transaction(xid);
+	}
+}
+
+/*
+ * Handle internal relation information.
+ *
+ * Update all relation details in the relation map cache.
+ */
+static void
+apply_handle_internal_relation(StringInfo s)
+{
+	int			num_rels;
+
+	num_rels = pq_getmsgint(s, 4);
+
+	for (int i = 0; i < num_rels; i++)
+	{
+		LogicalRepRelation *rel = logicalrep_read_rel(s);
+
+		logicalrep_relmap_update(rel);
+
+		elog(DEBUG1, "parallel apply worker worker init relmap for %s",
+			 rel->relname);
+	}
+}
+
 /*
  * Form the origin name for the subscription.
  *
@@ -3868,6 +3909,14 @@ apply_dispatch(StringInfo s)
 			apply_handle_stream_prepare(s);
 			break;
 
+		case LOGICAL_REP_MSG_INTERNAL_RELATION:
+			apply_handle_internal_relation(s);
+			break;
+
+		case LOGICAL_REP_MSG_INTERNAL_DEPENDENCY:
+			apply_handle_internal_dependency(s);
+			break;
+
 		default:
 			ereport(ERROR,
 					(errcode(ERRCODE_PROTOCOL_VIOLATION),
diff --git a/src/include/replication/logicalproto.h b/src/include/replication/logicalproto.h
index b261c60d3fa..5d91e2a4287 100644
--- a/src/include/replication/logicalproto.h
+++ b/src/include/replication/logicalproto.h
@@ -75,6 +75,8 @@ typedef enum LogicalRepMsgType
 	LOGICAL_REP_MSG_STREAM_COMMIT = 'c',
 	LOGICAL_REP_MSG_STREAM_ABORT = 'A',
 	LOGICAL_REP_MSG_STREAM_PREPARE = 'p',
+	LOGICAL_REP_MSG_INTERNAL_DEPENDENCY = 'd',
+	LOGICAL_REP_MSG_INTERNAL_RELATION = 'i',
 } LogicalRepMsgType;
 
 /*
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index f081619f151..a3526eae578 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -359,6 +359,8 @@ extern void pa_decr_and_wait_stream_block(void);
 extern void pa_xact_finish(ParallelApplyWorkerInfo *winfo,
 						   XLogRecPtr remote_lsn);
 
+extern void pa_wait_for_depended_transaction(TransactionId xid);
+
 #define isParallelApplyWorker(worker) ((worker)->in_use && \
 									   (worker)->type == WORKERTYPE_PARALLEL_APPLY)
 #define isTableSyncWorker(worker) ((worker)->in_use && \
@@ -366,6 +368,8 @@ extern void pa_xact_finish(ParallelApplyWorkerInfo *winfo,
 #define isSequenceSyncWorker(worker) ((worker)->in_use && \
 									  (worker)->type == WORKERTYPE_SEQUENCESYNC)
 
+#define PARALLEL_APPLY_INTERNAL_MESSAGE	'i'
+
 static inline bool
 am_tablesync_worker(void)
 {
-- 
2.47.3



  [application/octet-stream] v7-0002-Introduce-a-shared-hash-table-to-store-paralleliz.patch (8.7K, ../../TY7PR01MB145541DD6F5BE4CD403BA39FDF586A@TY7PR01MB14554.jpnprd01.prod.outlook.com/3-v7-0002-Introduce-a-shared-hash-table-to-store-paralleliz.patch)
  download | inline diff:
From 7bf35810bfcaa6b398919ea3064c6d5b9b6596f7 Mon Sep 17 00:00:00 2001
From: Hayato Kuroda <[email protected]>
Date: Mon, 1 Dec 2025 16:28:38 +0900
Subject: [PATCH v7 2/8] Introduce a shared hash table to store parallelized
 transactions

This hash table is used for ensuring that parallel workers wait until dependent
transactions are committed.

The shared hash table contains transaction IDs that the leader allocated to
parallel workers. The hash entries are inserted with a remote XID when the
leader bypasses remote transactions to parallel apply workers. Entries are
deleted when parallel workers are committed to corresponding transactions.

When the parallel worker tries to wait for other transactions, it checks the
hash table for the remote XIDs. The process can go ahead only when entries are
removed from the hash.

Author: Hou Zhijie <[email protected]>
Author: Hayato Kuroda <[email protected]>
---
 .../replication/logical/applyparallelworker.c | 100 +++++++++++++++++-
 .../utils/activity/wait_event_names.txt       |   1 +
 src/include/replication/worker_internal.h     |   4 +
 src/include/storage/lwlocklist.h              |   1 +
 4 files changed, 105 insertions(+), 1 deletion(-)

diff --git a/src/backend/replication/logical/applyparallelworker.c b/src/backend/replication/logical/applyparallelworker.c
index 055feea0bc5..6ca5f778a3b 100644
--- a/src/backend/replication/logical/applyparallelworker.c
+++ b/src/backend/replication/logical/applyparallelworker.c
@@ -218,12 +218,35 @@ typedef struct ParallelApplyWorkerEntry
 	ParallelApplyWorkerInfo *winfo;
 } ParallelApplyWorkerEntry;
 
+/* an entry in the parallelized_txns shared hash table */
+typedef struct ParallelizedTxnEntry
+{
+	TransactionId xid;			/* Hash key */
+} ParallelizedTxnEntry;
+
 /*
  * A hash table used to cache the state of streaming transactions being applied
  * by the parallel apply workers.
  */
 static HTAB *ParallelApplyTxnHash = NULL;
 
+/*
+ * A hash table used to track the parallelized transactions that could be
+ * depended on by other transactions.
+ */
+static dsa_area *parallel_apply_dsa_area = NULL;
+static dshash_table *parallelized_txns = NULL;
+
+/* parameters for the parallelized_txns shared hash table */
+static const dshash_parameters dsh_params = {
+	sizeof(TransactionId),
+	sizeof(ParallelizedTxnEntry),
+	dshash_memcmp,
+	dshash_memhash,
+	dshash_memcpy,
+	LWTRANCHE_PARALLEL_APPLY_DSA
+};
+
 /*
 * A list (pool) of active parallel apply workers. The information for
 * the new worker is added to the list after successfully launching it. The
@@ -257,6 +280,8 @@ static List *subxactlist = NIL;
 static void pa_free_worker_info(ParallelApplyWorkerInfo *winfo);
 static ParallelTransState pa_get_xact_state(ParallelApplyWorkerShared *wshared);
 static PartialFileSetState pa_get_fileset_state(void);
+static void pa_attach_parallelized_txn_hash(dsa_handle *pa_dsa_handle,
+											dshash_table_handle *pa_dshash_handle);
 
 /*
  * Returns true if it is OK to start a parallel apply worker, false otherwise.
@@ -334,6 +359,15 @@ pa_setup_dsm(ParallelApplyWorkerInfo *winfo)
 	shm_mq	   *mq;
 	Size		queue_size = DSM_QUEUE_SIZE;
 	Size		error_queue_size = DSM_ERROR_QUEUE_SIZE;
+	dsa_handle	parallel_apply_dsa_handle;
+	dshash_table_handle parallelized_txns_handle;
+
+	pa_attach_parallelized_txn_hash(&parallel_apply_dsa_handle,
+									&parallelized_txns_handle);
+
+	if (parallel_apply_dsa_handle == DSA_HANDLE_INVALID ||
+		parallelized_txns_handle == DSHASH_HANDLE_INVALID)
+		return false;
 
 	/*
 	 * Estimate how much shared memory we need.
@@ -369,6 +403,8 @@ pa_setup_dsm(ParallelApplyWorkerInfo *winfo)
 	pg_atomic_init_u32(&(shared->pending_stream_count), 0);
 	shared->last_commit_end = InvalidXLogRecPtr;
 	shared->fileset_state = FS_EMPTY;
+	shared->parallel_apply_dsa_handle = parallel_apply_dsa_handle;
+	shared->parallelized_txns_handle = parallelized_txns_handle;
 
 	shm_toc_insert(toc, PARALLEL_APPLY_KEY_SHARED, shared);
 
@@ -864,6 +900,8 @@ ParallelApplyWorkerMain(Datum main_arg)
 	shm_mq	   *mq;
 	shm_mq_handle *mqh;
 	shm_mq_handle *error_mqh;
+	dsa_handle	pa_dsa_handle;
+	dshash_table_handle pa_dshash_handle;
 	RepOriginId originid;
 	int			worker_slot = DatumGetInt32(main_arg);
 	char		originname[NAMEDATALEN];
@@ -951,6 +989,8 @@ ParallelApplyWorkerMain(Datum main_arg)
 
 	InitializingApplyWorker = false;
 
+	pa_attach_parallelized_txn_hash(&pa_dsa_handle, &pa_dshash_handle);
+
 	/* Setup replication origin tracking. */
 	StartTransactionCommand();
 	ReplicationOriginNameForLogicalRep(MySubscription->oid, InvalidOid,
@@ -1646,6 +1686,51 @@ pa_xact_finish(ParallelApplyWorkerInfo *winfo, XLogRecPtr remote_lsn)
 	pa_free_worker(winfo);
 }
 
+/*
+ * Attach to the shared hash table for parallelized transactions.
+ */
+static void
+pa_attach_parallelized_txn_hash(dsa_handle *pa_dsa_handle,
+								dshash_table_handle *pa_dshash_handle)
+{
+	MemoryContext oldctx;
+
+	if (parallelized_txns)
+	{
+		Assert(parallel_apply_dsa_area);
+		*pa_dsa_handle = dsa_get_handle(parallel_apply_dsa_area);
+		*pa_dshash_handle = dshash_get_hash_table_handle(parallelized_txns);
+		return;
+	}
+
+	/* Be sure any local memory allocated by DSA routines is persistent. */
+	oldctx = MemoryContextSwitchTo(ApplyContext);
+
+	if (am_leader_apply_worker())
+	{
+		/* Initialize dynamic shared hash table for last-start times. */
+		parallel_apply_dsa_area = dsa_create(LWTRANCHE_PARALLEL_APPLY_DSA);
+		dsa_pin(parallel_apply_dsa_area);
+		dsa_pin_mapping(parallel_apply_dsa_area);
+		parallelized_txns = dshash_create(parallel_apply_dsa_area, &dsh_params, NULL);
+
+		/* Store handles in shared memory for other backends to use. */
+		*pa_dsa_handle = dsa_get_handle(parallel_apply_dsa_area);
+		*pa_dshash_handle = dshash_get_hash_table_handle(parallelized_txns);
+	}
+	else if (am_parallel_apply_worker())
+	{
+		/* Attach to existing dynamic shared hash table. */
+		parallel_apply_dsa_area = dsa_attach(MyParallelShared->parallel_apply_dsa_handle);
+		dsa_pin_mapping(parallel_apply_dsa_area);
+		parallelized_txns = dshash_attach(parallel_apply_dsa_area, &dsh_params,
+										  MyParallelShared->parallelized_txns_handle,
+										  NULL);
+	}
+
+	MemoryContextSwitchTo(oldctx);
+}
+
 /*
  * Wait for the given transaction to finish.
  */
@@ -1656,7 +1741,20 @@ pa_wait_for_depended_transaction(TransactionId xid)
 
 	for (;;)
 	{
-		/* XXX wait until given transaction is finished */
+		ParallelizedTxnEntry *txn_entry;
+
+		txn_entry = dshash_find(parallelized_txns, &xid, false);
+
+		/* The entry is removed only if the transaction is committed */
+		if (txn_entry == NULL)
+			break;
+
+		dshash_release_lock(parallelized_txns, txn_entry);
+
+		pa_lock_transaction(xid, AccessShareLock);
+		pa_unlock_transaction(xid, AccessShareLock);
+
+		CHECK_FOR_INTERRUPTS();
 	}
 
 	elog(DEBUG1, "finish waiting for depended xid %u", xid);
diff --git a/src/backend/utils/activity/wait_event_names.txt b/src/backend/utils/activity/wait_event_names.txt
index dcfadbd5aae..53b87a2df10 100644
--- a/src/backend/utils/activity/wait_event_names.txt
+++ b/src/backend/utils/activity/wait_event_names.txt
@@ -406,6 +406,7 @@ SubtransSLRU	"Waiting to access the sub-transaction SLRU cache."
 XactSLRU	"Waiting to access the transaction status SLRU cache."
 ParallelVacuumDSA	"Waiting for parallel vacuum dynamic shared memory allocation."
 AioUringCompletion	"Waiting for another process to complete IO via io_uring."
+ParallelApplyDSA	"Waiting for parallel apply dynamic shared memory allocation."
 
 # No "ABI_compatibility" region here as WaitEventLWLock has its own C code.
 
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index a3526eae578..ddcdcc05053 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -15,6 +15,7 @@
 #include "access/xlogdefs.h"
 #include "catalog/pg_subscription.h"
 #include "datatype/timestamp.h"
+#include "lib/dshash.h"
 #include "miscadmin.h"
 #include "replication/logicalrelation.h"
 #include "replication/walreceiver.h"
@@ -197,6 +198,9 @@ typedef struct ParallelApplyWorkerShared
 	 */
 	PartialFileSetState fileset_state;
 	FileSet		fileset;
+
+	dsa_handle	parallel_apply_dsa_handle;
+	dshash_table_handle parallelized_txns_handle;
 } ParallelApplyWorkerShared;
 
 /*
diff --git a/src/include/storage/lwlocklist.h b/src/include/storage/lwlocklist.h
index 533344509e9..e16295e5a3b 100644
--- a/src/include/storage/lwlocklist.h
+++ b/src/include/storage/lwlocklist.h
@@ -137,3 +137,4 @@ PG_LWLOCKTRANCHE(SUBTRANS_SLRU, SubtransSLRU)
 PG_LWLOCKTRANCHE(XACT_SLRU, XactSLRU)
 PG_LWLOCKTRANCHE(PARALLEL_VACUUM_DSA, ParallelVacuumDSA)
 PG_LWLOCKTRANCHE(AIO_URING_COMPLETION, AioUringCompletion)
+PG_LWLOCKTRANCHE(PARALLEL_APPLY_DSA, ParallelApplyDSA)
-- 
2.47.3



  [application/octet-stream] v7-0003-Introduce-a-local-hash-table-to-store-replica-ide.patch (27.7K, ../../TY7PR01MB145541DD6F5BE4CD403BA39FDF586A@TY7PR01MB14554.jpnprd01.prod.outlook.com/4-v7-0003-Introduce-a-local-hash-table-to-store-replica-ide.patch)
  download | inline diff:
From 56c965288a81f8c3bae36d651f7ca6467772b930 Mon Sep 17 00:00:00 2001
From: Hayato Kuroda <[email protected]>
Date: Mon, 1 Dec 2025 16:39:02 +0900
Subject: [PATCH v7 3/8] Introduce a local hash table to store replica
 identities

This local hash table on the leader is used for detecting dependencies between
transactions.

The hash contains the Replica Identity (RI) as a key and the remote XID that
modified the corresponding tuple. The hash entries are inserted when the leader
finds an RI from a replication message. Entries are deleted when transactions
committed by parallel workers are gathered, or the number of entries exceeds the
limit.

When the leader sends replication changes to parallel workers, it checks whether
other transactions have already used the RI associated with the change. If
something is found, the leader treats it as a dependent transaction and notifies
parallel workers to wait until it finishes via LOGICAL_REP_MSG_INTERNAL_DEPENDENCY.

Author: Hou Zhijie <[email protected]>
Author: Hayato Kuroda <[email protected]>
---
 .../replication/logical/applyparallelworker.c | 123 +++-
 src/backend/replication/logical/relation.c    |  24 +
 src/backend/replication/logical/worker.c      | 616 +++++++++++++++++-
 src/include/replication/logicalrelation.h     |   3 +
 src/include/replication/worker_internal.h     |   8 +-
 5 files changed, 771 insertions(+), 3 deletions(-)

diff --git a/src/backend/replication/logical/applyparallelworker.c b/src/backend/replication/logical/applyparallelworker.c
index 6ca5f778a3b..cf08206d9fd 100644
--- a/src/backend/replication/logical/applyparallelworker.c
+++ b/src/backend/replication/logical/applyparallelworker.c
@@ -216,6 +216,7 @@ typedef struct ParallelApplyWorkerEntry
 {
 	TransactionId xid;			/* Hash key -- must be first */
 	ParallelApplyWorkerInfo *winfo;
+	XLogRecPtr	local_end;
 } ParallelApplyWorkerEntry;
 
 /* an entry in the parallelized_txns shared hash table */
@@ -504,7 +505,7 @@ pa_launch_parallel_worker(void)
  * streaming changes.
  */
 void
-pa_allocate_worker(TransactionId xid)
+pa_allocate_worker(TransactionId xid, bool stream_txn)
 {
 	bool		found;
 	ParallelApplyWorkerInfo *winfo = NULL;
@@ -545,7 +546,9 @@ pa_allocate_worker(TransactionId xid)
 
 	winfo->in_use = true;
 	winfo->serialize_changes = false;
+	winfo->stream_txn = stream_txn;
 	entry->winfo = winfo;
+	entry->local_end = InvalidXLogRecPtr;
 }
 
 /*
@@ -742,6 +745,73 @@ pa_process_spooled_messages_if_required(void)
 	return true;
 }
 
+/*
+ * Get the local end LSN for a transaction applied by a parallel apply worker.
+ *
+ * Set delete_entry to true if you intend to remove the transaction from the
+ * ParallelApplyTxnHash after collecting its LSN.
+ *
+ * If the parallel apply worker did not write any changes during the transaction
+ * application due to situations like update/delete_missing or a before trigger,
+ * the *skipped_write will be set to true.
+ */
+XLogRecPtr
+pa_get_last_commit_end(TransactionId xid, bool delete_entry, bool *skipped_write)
+{
+	bool		found;
+	ParallelApplyWorkerEntry *entry;
+	ParallelApplyWorkerInfo *winfo;
+
+	Assert(TransactionIdIsValid(xid));
+
+	if (skipped_write)
+		*skipped_write = false;
+
+	/* Find an entry for the requested transaction. */
+	entry = hash_search(ParallelApplyTxnHash, &xid, HASH_FIND, &found);
+
+	if (!found)
+		return InvalidXLogRecPtr;
+
+	/*
+	 * If worker info is NULL, it indicates that the worker has been reused
+	 * for handling other transactions. Consequently, the local end LSN has
+	 * already been collected and saved in entry->local_end.
+	 */
+	winfo = entry->winfo;
+	if (winfo == NULL)
+	{
+		if (delete_entry &&
+			!hash_search(ParallelApplyTxnHash, &xid, HASH_REMOVE, NULL))
+			elog(ERROR, "hash table corrupted");
+
+		if (skipped_write)
+			*skipped_write = XLogRecPtrIsInvalid(entry->local_end);
+
+		return entry->local_end;
+	}
+
+	/* Return InvalidXLogRecPtr if the transaction is still in progress */
+	if (pa_get_xact_state(winfo->shared) != PARALLEL_TRANS_FINISHED)
+		return InvalidXLogRecPtr;
+
+	/* Collect the local end LSN from the worker's shared memory area */
+	entry->local_end = winfo->shared->last_commit_end;
+	entry->winfo = NULL;
+
+	if (skipped_write)
+		*skipped_write = XLogRecPtrIsInvalid(entry->local_end);
+
+	elog(DEBUG1, "store local commit %X/%X end to txn entry: %u",
+		 LSN_FORMAT_ARGS(entry->local_end), xid);
+
+	if (delete_entry &&
+		!hash_search(ParallelApplyTxnHash, &xid, HASH_REMOVE, NULL))
+		elog(ERROR, "hash table corrupted");
+
+	return entry->local_end;
+}
+
 /*
  * Interrupt handler for main loop of parallel apply worker.
  */
@@ -1686,6 +1756,26 @@ pa_xact_finish(ParallelApplyWorkerInfo *winfo, XLogRecPtr remote_lsn)
 	pa_free_worker(winfo);
 }
 
+bool
+pa_transaction_committed(TransactionId xid)
+{
+	bool		found;
+	ParallelApplyWorkerEntry *entry;
+
+	Assert(TransactionIdIsValid(xid));
+
+	/* Find an entry for the requested transaction */
+	entry = hash_search(ParallelApplyTxnHash, &xid, HASH_FIND, &found);
+
+	if (!found)
+		return true;
+
+	if (!entry->winfo)
+		return true;
+
+	return pa_get_xact_state(entry->winfo->shared) == PARALLEL_TRANS_FINISHED;
+}
+
 /*
  * Attach to the shared hash table for parallelized transactions.
  */
@@ -1731,6 +1821,37 @@ pa_attach_parallelized_txn_hash(dsa_handle *pa_dsa_handle,
 	MemoryContextSwitchTo(oldctx);
 }
 
+/*
+ * Record in-progress transactions from the given list that are being depended
+ * on into the shared hash table.
+ */
+void
+pa_record_dependency_on_transactions(List *depends_on_xids)
+{
+	foreach_xid(xid, depends_on_xids)
+	{
+		bool		found;
+		ParallelApplyWorkerEntry *winfo_entry;
+		ParallelApplyWorkerInfo *winfo;
+		ParallelizedTxnEntry *txn_entry;
+
+		winfo_entry = hash_search(ParallelApplyTxnHash, &xid, HASH_FIND, &found);
+		winfo = winfo_entry->winfo;
+
+		txn_entry = dshash_find_or_insert(parallelized_txns, &xid, &found);
+
+		/*
+		 * If the transaction has been committed now, remove the entry,
+		 * otherwise the parallel apply worker will remove the entry once
+		 * committed the transaction.
+		 */
+		if (pa_get_xact_state(winfo->shared) == PARALLEL_TRANS_FINISHED)
+			dshash_delete_entry(parallelized_txns, txn_entry);
+		else
+			dshash_release_lock(parallelized_txns, txn_entry);
+	}
+}
+
 /*
  * Wait for the given transaction to finish.
  */
diff --git a/src/backend/replication/logical/relation.c b/src/backend/replication/logical/relation.c
index 2c8485b881f..13f8cb74e9f 100644
--- a/src/backend/replication/logical/relation.c
+++ b/src/backend/replication/logical/relation.c
@@ -959,3 +959,27 @@ FindLogicalRepLocalIndex(Relation localrel, LogicalRepRelation *remoterel,
 
 	return InvalidOid;
 }
+
+/*
+ * Get the LogicalRepRelMapEntry corresponding to the given relid without
+ * opening the local relation.
+ */
+LogicalRepRelMapEntry *
+logicalrep_get_relentry(LogicalRepRelId remoteid)
+{
+	LogicalRepRelMapEntry *entry;
+	bool		found;
+
+	if (LogicalRepRelMap == NULL)
+		logicalrep_relmap_init();
+
+	/* Search for existing entry. */
+	entry = hash_search(LogicalRepRelMap, (void *) &remoteid,
+						HASH_FIND, &found);
+
+	if (!found)
+		elog(DEBUG1, "no relation map entry for remote relation ID %u",
+			 remoteid);
+
+	return entry;
+}
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 73d38644c4a..0b1eeefe9c9 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -303,6 +303,7 @@ typedef struct FlushPosition
 	dlist_node	node;
 	XLogRecPtr	local_end;
 	XLogRecPtr	remote_end;
+	TransactionId pa_remote_xid;
 } FlushPosition;
 
 static dlist_head lsn_mapping = DLIST_STATIC_INIT(lsn_mapping);
@@ -544,6 +545,49 @@ typedef struct ApplySubXactData
 
 static ApplySubXactData subxact_data = {0, 0, InvalidTransactionId, NULL};
 
+typedef struct ReplicaIdentityKey
+{
+	Oid			relid;
+	LogicalRepTupleData *data;
+} ReplicaIdentityKey;
+
+typedef struct ReplicaIdentityEntry
+{
+	ReplicaIdentityKey *keydata;
+	TransactionId remote_xid;
+
+	/* needed for simplehash */
+	uint32		hash;
+	char		status;
+} ReplicaIdentityEntry;
+
+#include "common/hashfn.h"
+
+static uint32 hash_replica_identity(ReplicaIdentityKey *key);
+static bool hash_replica_identity_compare(ReplicaIdentityKey *a,
+										  ReplicaIdentityKey *b);
+
+/* Define parameters for replica identity hash table code generation. */
+#define SH_PREFIX		replica_identity
+#define SH_ELEMENT_TYPE	ReplicaIdentityEntry
+#define SH_KEY_TYPE		ReplicaIdentityKey *
+#define	SH_KEY			keydata
+#define SH_HASH_KEY(tb, key)	hash_replica_identity(key)
+#define SH_EQUAL(tb, a, b)		hash_replica_identity_compare(a, b)
+#define SH_STORE_HASH
+#define SH_GET_HASH(tb, a) (a)->hash
+#define	SH_SCOPE		static inline
+#define SH_DECLARE
+#define SH_DEFINE
+#include "lib/simplehash.h"
+
+#define REPLICA_IDENTITY_INITIAL_SIZE 128
+#define REPLICA_IDENTITY_CLEANUP_THRESHOLD 1024
+
+static replica_identity_hash *replica_identity_table = NULL;
+
+static void write_internal_dependencies(StringInfo s, List *depends_on_xids);
+
 static inline void subxact_filename(char *path, Oid subid, TransactionId xid);
 static inline void changes_filename(char *path, Oid subid, TransactionId xid);
 
@@ -629,6 +673,546 @@ static TransApplyAction get_transaction_apply_action(TransactionId xid,
 
 static void replorigin_reset(int code, Datum arg);
 
+static bool send_internal_dependencies(ParallelApplyWorkerInfo *winfo,
+									   StringInfo s);
+
+/*
+ * Compute the hash value for entries in the replica_identity_table.
+ */
+static uint32
+hash_replica_identity(ReplicaIdentityKey *key)
+{
+	int			i;
+	uint32		hashkey = 0;
+
+	hashkey = hash_combine(hashkey, hash_uint32(key->relid));
+
+	for (i = 0; i < key->data->ncols; i++)
+	{
+		uint32		hkey;
+
+		if (key->data->colstatus[i] == LOGICALREP_COLUMN_NULL)
+			continue;
+
+		hkey = hash_any((const unsigned char *) key->data->colvalues[i].data,
+						key->data->colvalues[i].len);
+		hashkey = hash_combine(hashkey, hkey);
+	}
+
+	return hashkey;
+}
+
+/*
+ * Compare two entries in the replica_identity_table.
+ */
+static bool
+hash_replica_identity_compare(ReplicaIdentityKey *a, ReplicaIdentityKey *b)
+{
+	if (a->relid != b->relid ||
+		a->data->ncols != b->data->ncols)
+		return false;
+
+	for (int i = 0; i < a->data->ncols; i++)
+	{
+		if (a->data->colstatus[i] != b->data->colstatus[i])
+			return false;
+
+		if (a->data->colvalues[i].len != b->data->colvalues[i].len)
+			return false;
+
+		if (strcmp(a->data->colvalues[i].data, b->data->colvalues[i].data))
+			return false;
+
+		elog(DEBUG1, "conflicting key %s", a->data->colvalues[i].data);
+	}
+
+	return true;
+}
+
+/*
+ * Free resources associated with a replica identity key.
+ */
+static void
+free_replica_identity_key(ReplicaIdentityKey *key)
+{
+	Assert(key);
+
+	pfree(key->data->colvalues);
+	pfree(key->data->colstatus);
+	pfree(key->data);
+	pfree(key);
+}
+
+/*
+ * Clean up hash table entries associated with the given transaction IDs.
+ */
+static void
+cleanup_replica_identity_table(List *committed_xid)
+{
+	replica_identity_iterator i;
+	ReplicaIdentityEntry *rientry;
+
+	if (!committed_xid)
+		return;
+
+	replica_identity_start_iterate(replica_identity_table, &i);
+	while ((rientry = replica_identity_iterate(replica_identity_table, &i)) != NULL)
+	{
+		if (!list_member_xid(committed_xid, rientry->remote_xid))
+			continue;
+
+		/* Clean up the hash entry for committed transaction */
+		free_replica_identity_key(rientry->keydata);
+		replica_identity_delete_item(replica_identity_table, rientry);
+	}
+}
+
+/*
+ * Check committed transactions and clean up corresponding entries in the hash
+ * table.
+ */
+static void
+cleanup_committed_replica_identity_entries(void)
+{
+	dlist_mutable_iter iter;
+	List	   *committed_xids = NIL;
+
+	dlist_foreach_modify(iter, &lsn_mapping)
+	{
+		FlushPosition *pos =
+			dlist_container(FlushPosition, node, iter.cur);
+		bool		skipped_write;
+
+		if (!TransactionIdIsValid(pos->pa_remote_xid) ||
+			!XLogRecPtrIsInvalid(pos->local_end))
+			continue;
+
+		pos->local_end = pa_get_last_commit_end(pos->pa_remote_xid, true,
+												&skipped_write);
+
+		elog(DEBUG1,
+			 "got commit end from parallel apply worker, "
+			 "txn: %u, remote_end %X/%X, local_end %X/%X",
+			 pos->pa_remote_xid, LSN_FORMAT_ARGS(pos->remote_end),
+			 LSN_FORMAT_ARGS(pos->local_end));
+
+		if (!skipped_write && XLogRecPtrIsInvalid(pos->local_end))
+			continue;
+
+		committed_xids = lappend_xid(committed_xids, pos->pa_remote_xid);
+	}
+
+	/* cleanup the entries for committed transactions */
+	cleanup_replica_identity_table(committed_xids);
+}
+
+/*
+ * Append a transaction dependency, excluding duplicates and committed
+ * transactions.
+ */
+static List *
+check_and_append_xid_dependency(List *depends_on_xids,
+								TransactionId *depends_on_xid,
+								TransactionId current_xid)
+{
+	Assert(depends_on_xid);
+
+	if (!TransactionIdIsValid(*depends_on_xid))
+		return depends_on_xids;
+
+	if (TransactionIdEquals(*depends_on_xid, current_xid))
+		return depends_on_xids;
+
+	if (list_member_xid(depends_on_xids, *depends_on_xid))
+		return depends_on_xids;
+
+	/*
+	 * Return and reset the xid if the transaction has been committed.
+	 */
+	if (pa_transaction_committed(*depends_on_xid))
+	{
+		*depends_on_xid = InvalidTransactionId;
+		return depends_on_xids;
+	}
+
+	return lappend_xid(depends_on_xids, *depends_on_xid);
+}
+
+/*
+ * Check for dependencies on preceding transactions that modify the same key.
+ * Returns the dependent transactions in 'depends_on_xids' and records the
+ * current change.
+ */
+static void
+check_dependency_on_replica_identity(Oid relid,
+									 LogicalRepTupleData *original_data,
+									 TransactionId new_depended_xid,
+									 List **depends_on_xids)
+{
+	LogicalRepRelMapEntry *relentry;
+	LogicalRepTupleData *ridata;
+	ReplicaIdentityKey *rikey;
+	ReplicaIdentityEntry *rientry;
+	MemoryContext oldctx;
+	int			n_ri;
+	bool		found = false;
+
+	Assert(depends_on_xids);
+
+	/* Search for existing entry */
+	relentry = logicalrep_get_relentry(relid);
+
+	Assert(relentry);
+
+	/*
+	 * First search whether any previous transaction has affected the whole
+	 * table e.g., truncate or schema change from publisher.
+	 */
+	*depends_on_xids = check_and_append_xid_dependency(*depends_on_xids,
+													   &relentry->last_depended_xid,
+													   new_depended_xid);
+
+	n_ri = bms_num_members(relentry->remoterel.attkeys);
+
+	/*
+	 * Return if there are no replica identity columns, indicating that the
+	 * remote relation has neither a replica identity key nor is marked as
+	 * replica identity full.
+	 */
+	if (!n_ri)
+		return;
+
+	/* Check if the RI key value of the tuple is invalid */
+	for (int i = 0; i < original_data->ncols; i++)
+	{
+		if (!bms_is_member(i, relentry->remoterel.attkeys))
+			continue;
+
+		/*
+		 * Return if RI key is NULL or is explicitly marked unchanged. The key
+		 * value could be NULL in the new tuple of a update opertaion which
+		 * means the RI key is not updated.
+		 */
+		if (original_data->colstatus[i] == LOGICALREP_COLUMN_NULL ||
+			original_data->colstatus[i] == LOGICALREP_COLUMN_UNCHANGED)
+			return;
+	}
+
+	oldctx = MemoryContextSwitchTo(ApplyContext);
+
+	/* Allocate space for replica identity values */
+	ridata = palloc0_object(LogicalRepTupleData);
+	ridata->colvalues = palloc0_array(StringInfoData, n_ri);
+	ridata->colstatus = palloc0_array(char, n_ri);
+	ridata->ncols = n_ri;
+
+	for (int i_original = 0, i_ri = 0; i_original < original_data->ncols; i_original++)
+	{
+		StringInfo	original_colvalue = &original_data->colvalues[i_original];
+
+		if (!bms_is_member(i_original, relentry->remoterel.attkeys))
+			continue;
+
+		initStringInfoExt(&ridata->colvalues[i_ri], original_colvalue->len + 1);
+		appendStringInfoString(&ridata->colvalues[i_ri], original_colvalue->data);
+		ridata->colstatus[i_ri] = original_data->colstatus[i_original];
+		i_ri++;
+	}
+
+	rikey = palloc0_object(ReplicaIdentityKey);
+	rikey->relid = relid;
+	rikey->data = ridata;
+
+	if (TransactionIdIsValid(new_depended_xid))
+	{
+		rientry = replica_identity_insert(replica_identity_table, rikey,
+										  &found);
+
+		/*
+		 * Release the key built to search the entry, if the entry already
+		 * exists. Otherwise, initialize the remote_xid.
+		 */
+		if (found)
+		{
+			elog(DEBUG1, "found conflicting replica identity change from %u",
+				 rientry->remote_xid);
+
+			free_replica_identity_key(rikey);
+		}
+		else
+			rientry->remote_xid = InvalidTransactionId;
+	}
+	else
+	{
+		rientry = replica_identity_lookup(replica_identity_table, rikey);
+		free_replica_identity_key(rikey);
+	}
+
+	MemoryContextSwitchTo(oldctx);
+
+	/* Return if no entry found */
+	if (!rientry)
+		return;
+
+	Assert(!found || TransactionIdIsValid(rientry->remote_xid));
+
+	*depends_on_xids = check_and_append_xid_dependency(*depends_on_xids,
+													   &rientry->remote_xid,
+													   new_depended_xid);
+
+	/*
+	 * Update the new depended xid into the entry if valid, the new xid could
+	 * be invalid if the transaction will be applied by the leader itself
+	 * which means all the changes will be committed before processing next
+	 * transaction, so no need to be depended on.
+	 */
+	if (TransactionIdIsValid(new_depended_xid))
+		rientry->remote_xid = new_depended_xid;
+
+	/*
+	 * Remove the entry if the transaction has been committed and no new
+	 * dependency needs to be added.
+	 */
+	else if (!TransactionIdIsValid(rientry->remote_xid))
+	{
+		free_replica_identity_key(rientry->keydata);
+		replica_identity_delete_item(replica_identity_table, rientry);
+	}
+}
+
+/*
+ * Check for preceding transactions that involve insert, delete, or update
+ * operations on the specified table, and return them in 'depends_on_xids'.
+ */
+static void
+find_all_dependencies_on_rel(LogicalRepRelId relid, TransactionId new_depended_xid,
+							 List **depends_on_xids)
+{
+	replica_identity_iterator i;
+	ReplicaIdentityEntry *rientry;
+
+	Assert(depends_on_xids);
+
+	replica_identity_start_iterate(replica_identity_table, &i);
+	while ((rientry = replica_identity_iterate(replica_identity_table, &i)) != NULL)
+	{
+		Assert(TransactionIdIsValid(rientry->remote_xid));
+
+		if (rientry->keydata->relid != relid)
+			continue;
+
+		/* Clean up the hash entry for committed transaction while on it */
+		if (pa_transaction_committed(rientry->remote_xid))
+		{
+			free_replica_identity_key(rientry->keydata);
+			replica_identity_delete_item(replica_identity_table, rientry);
+
+			continue;
+		}
+
+		*depends_on_xids = check_and_append_xid_dependency(*depends_on_xids,
+														   &rientry->remote_xid,
+														   new_depended_xid);
+	}
+}
+
+/*
+ * Check for any preceding transactions that affect the given table and returns
+ * them in 'depends_on_xids'.
+ */
+static void
+check_dependency_on_rel(LogicalRepRelId relid, TransactionId new_depended_xid,
+						List **depends_on_xids)
+{
+	LogicalRepRelMapEntry *relentry;
+
+	Assert(depends_on_xids);
+
+	find_all_dependencies_on_rel(relid, new_depended_xid, depends_on_xids);
+
+	/* Search for existing entry */
+	relentry = logicalrep_get_relentry(relid);
+
+	/*
+	 * The relentry has not been initialized yet, indicating no change has
+	 * been applide yet.
+	 */
+	if (!relentry)
+		return;
+
+	*depends_on_xids = check_and_append_xid_dependency(*depends_on_xids,
+													   &relentry->last_depended_xid,
+													   new_depended_xid);
+
+	if (TransactionIdIsValid(new_depended_xid))
+		relentry->last_depended_xid = new_depended_xid;
+}
+
+/*
+ * Check dependencies related to the current change by determining if the
+ * modification impacts the same row or table as another ongoing transaction. If
+ * needed, instruct parallel apply workers to wait for these preceding
+ * transactions to complete.
+ *
+ * Simultaneously, track the dependency for the current change to ensure that
+ * subsequent transactions address this dependency.
+ */
+static void
+handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
+							TransactionId new_depended_xid,
+							ParallelApplyWorkerInfo *winfo)
+{
+	LogicalRepRelId relid;
+	LogicalRepTupleData oldtup;
+	LogicalRepTupleData newtup;
+	LogicalRepRelation *rel;
+	List	   *depends_on_xids = NIL;
+	List	   *remote_relids;
+	bool		has_oldtup = false;
+	bool		cascade = false;
+	bool		restart_seqs = false;
+	StringInfoData dependencies;
+
+	/*
+	 * Parse the consume data using a local copy instead of directly consuming
+	 * the given remote change as the caller may also read the data from the
+	 * remote message.
+	 */
+	StringInfoData change = *s;
+
+	/* Compute dependency only for non-streaming transaction */
+	if (in_streamed_transaction || (winfo && winfo->stream_txn))
+		return;
+
+	/* Only the leader checks dependencies and schedules the parallel apply */
+	if (!am_leader_apply_worker())
+		return;
+
+	if (!replica_identity_table)
+		replica_identity_table = replica_identity_create(ApplyContext,
+														 REPLICA_IDENTITY_INITIAL_SIZE,
+														 NULL);
+
+	if (replica_identity_table->members >= REPLICA_IDENTITY_CLEANUP_THRESHOLD)
+		cleanup_committed_replica_identity_entries();
+
+	switch (action)
+	{
+		case LOGICAL_REP_MSG_INSERT:
+			relid = logicalrep_read_insert(&change, &newtup);
+			check_dependency_on_replica_identity(relid, &newtup,
+												 new_depended_xid,
+												 &depends_on_xids);
+			break;
+
+		case LOGICAL_REP_MSG_UPDATE:
+			relid = logicalrep_read_update(&change, &has_oldtup, &oldtup,
+										   &newtup);
+
+			if (has_oldtup)
+				check_dependency_on_replica_identity(relid, &oldtup,
+													 new_depended_xid,
+													 &depends_on_xids);
+
+			check_dependency_on_replica_identity(relid, &newtup,
+												 new_depended_xid,
+												 &depends_on_xids);
+			break;
+
+		case LOGICAL_REP_MSG_DELETE:
+			relid = logicalrep_read_delete(&change, &oldtup);
+			check_dependency_on_replica_identity(relid, &oldtup,
+												 new_depended_xid,
+												 &depends_on_xids);
+			break;
+
+		case LOGICAL_REP_MSG_TRUNCATE:
+			remote_relids = logicalrep_read_truncate(&change, &cascade,
+													 &restart_seqs);
+
+			/*
+			 * Truncate affects all rows in a table, so the current
+			 * transaction should wait for all preceding transactions that
+			 * modified the same table.
+			 */
+			foreach_int(truncated_relid, remote_relids)
+				check_dependency_on_rel(truncated_relid, new_depended_xid,
+										&depends_on_xids);
+
+			break;
+
+		case LOGICAL_REP_MSG_RELATION:
+			rel = logicalrep_read_rel(&change);
+
+			/*
+			 * The replica identity key could be changed, making existing
+			 * entries in the replica identity invalid. In this case, parallel
+			 * apply is not allowed on this specific table until all running
+			 * transactions that modified it have finished.
+			 */
+			check_dependency_on_rel(rel->remoteid, new_depended_xid,
+									&depends_on_xids);
+			break;
+
+		case LOGICAL_REP_MSG_TYPE:
+		case LOGICAL_REP_MSG_MESSAGE:
+
+			/*
+			 * Type updates accompany relation updates, so dependencies have
+			 * already been checked during relation updates. Logical messages
+			 * do not conflict with any changes, so they can be ignored.
+			 */
+			break;
+
+		default:
+			Assert(false);
+			break;
+	}
+
+	if (!depends_on_xids)
+		return;
+
+	/*
+	 * Notify the transactions that they are dependent on the current
+	 * transaction.
+	 */
+	pa_record_dependency_on_transactions(depends_on_xids);
+
+	/*
+	 * If the leader applies the transaction itself, start waiting for
+	 * transactions that depend on the current transaction immediately.
+	 */
+	if (winfo == NULL)
+	{
+		foreach_xid(xid, depends_on_xids)
+			pa_wait_for_depended_transaction(xid);
+
+		return;
+	}
+
+	initStringInfo(&dependencies);
+
+	/* Build the dependency message used to send to parallel apply worker */
+	write_internal_dependencies(&dependencies, depends_on_xids);
+
+	(void) send_internal_dependencies(winfo, &dependencies);
+}
+
+/*
+ * Write internal dependency information to the output for the parallel apply
+ * worker.
+ */
+static void
+write_internal_dependencies(StringInfo s, List *depends_on_xids)
+{
+	pq_sendbyte(s, PARALLEL_APPLY_INTERNAL_MESSAGE);
+	pq_sendbyte(s, LOGICAL_REP_MSG_INTERNAL_DEPENDENCY);
+	pq_sendint32(s, list_length(depends_on_xids));
+
+	foreach_xid(xid, depends_on_xids)
+		pq_sendint32(s, xid);
+}
+
 /*
  * Handle internal dependency information.
  *
@@ -826,7 +1410,10 @@ handle_streamed_transaction(LogicalRepMsgType action, StringInfo s)
 
 	/* not in streaming mode */
 	if (apply_action == TRANS_LEADER_APPLY)
+	{
+		handle_dependency_on_change(action, s, InvalidTransactionId, winfo);
 		return false;
+	}
 
 	Assert(TransactionIdIsValid(stream_xid));
 
@@ -1268,6 +1855,33 @@ apply_handle_begin(StringInfo s)
 	pgstat_report_activity(STATE_RUNNING, NULL);
 }
 
+/*
+ * Send an INTERNAL_DEPENDENCY message to a parallel apply worker.
+ *
+ * Returns false if we switched to the serialize mode to send the message,
+ * true otherwise.
+ */
+static bool
+send_internal_dependencies(ParallelApplyWorkerInfo *winfo, StringInfo s)
+{
+	Assert(s->data[0] == PARALLEL_APPLY_INTERNAL_MESSAGE);
+	Assert(s->data[1] == LOGICAL_REP_MSG_INTERNAL_DEPENDENCY);
+
+	if (!winfo->serialize_changes)
+	{
+		if (pa_send_data(winfo, s->len, s->data))
+			return true;
+
+		pa_switch_to_partial_serialize(winfo, true);
+	}
+
+	/* Skip writing the first internal message flag */
+	s->cursor++;
+	stream_write_change(LOGICAL_REP_MSG_INTERNAL_DEPENDENCY, s);
+
+	return false;
+}
+
 /*
  * Handle COMMIT message.
  *
@@ -1795,7 +2409,7 @@ apply_handle_stream_start(StringInfo s)
 
 	/* Try to allocate a worker for the streaming transaction. */
 	if (first_segment)
-		pa_allocate_worker(stream_xid);
+		pa_allocate_worker(stream_xid, true);
 
 	apply_action = get_transaction_apply_action(stream_xid, &winfo);
 
diff --git a/src/include/replication/logicalrelation.h b/src/include/replication/logicalrelation.h
index 7a561a8e8d8..4b321bd2ad2 100644
--- a/src/include/replication/logicalrelation.h
+++ b/src/include/replication/logicalrelation.h
@@ -37,6 +37,8 @@ typedef struct LogicalRepRelMapEntry
 	/* Sync state. */
 	char		state;
 	XLogRecPtr	statelsn;
+
+	TransactionId last_depended_xid;
 } LogicalRepRelMapEntry;
 
 extern void logicalrep_relmap_update(LogicalRepRelation *remoterel);
@@ -50,5 +52,6 @@ extern void logicalrep_rel_close(LogicalRepRelMapEntry *rel,
 								 LOCKMODE lockmode);
 extern bool IsIndexUsableForReplicaIdentityFull(Relation idxrel, AttrMap *attrmap);
 extern Oid	GetRelationIdentityOrPK(Relation rel);
+extern LogicalRepRelMapEntry *logicalrep_get_relentry(LogicalRepRelId remoteid);
 
 #endif							/* LOGICALRELATION_H */
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index ddcdcc05053..78b5667cebe 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -235,6 +235,8 @@ typedef struct ParallelApplyWorkerInfo
 	 */
 	bool		in_use;
 
+	bool		stream_txn;
+
 	ParallelApplyWorkerShared *shared;
 } ParallelApplyWorkerInfo;
 
@@ -332,8 +334,10 @@ extern void apply_error_callback(void *arg);
 extern void set_apply_error_context_origin(char *originname);
 
 /* Parallel apply worker setup and interactions */
-extern void pa_allocate_worker(TransactionId xid);
+extern void pa_allocate_worker(TransactionId xid, bool stream_txn);
 extern ParallelApplyWorkerInfo *pa_find_worker(TransactionId xid);
+extern XLogRecPtr pa_get_last_commit_end(TransactionId xid, bool delete_entry,
+										 bool *skipped_write);
 extern void pa_detach_all_error_mq(void);
 
 extern bool pa_send_data(ParallelApplyWorkerInfo *winfo, Size nbytes,
@@ -362,6 +366,8 @@ extern void pa_decr_and_wait_stream_block(void);
 
 extern void pa_xact_finish(ParallelApplyWorkerInfo *winfo,
 						   XLogRecPtr remote_lsn);
+extern bool pa_transaction_committed(TransactionId xid);
+extern void pa_record_dependency_on_transactions(List *depends_on_xids);
 
 extern void pa_wait_for_depended_transaction(TransactionId xid);
 
-- 
2.47.3



  [application/octet-stream] v7-0004-Parallel-apply-non-streaming-transactions.patch (51.7K, ../../TY7PR01MB145541DD6F5BE4CD403BA39FDF586A@TY7PR01MB14554.jpnprd01.prod.outlook.com/5-v7-0004-Parallel-apply-non-streaming-transactions.patch)
  download | inline diff:
From 776c076c23cb632ec97a4314cb414748e6f9f3d0 Mon Sep 17 00:00:00 2001
From: Hayato Kuroda <[email protected]>
Date: Mon, 1 Dec 2025 12:28:29 +0900
Subject: [PATCH v7 4/8] Parallel apply non-streaming transactions

--
Basic design
--

The leader worker assigns each non-streaming transaction to a parallel apply
worker. Before dispatching changes to a parallel worker, the leader verifies if
the current modification affects the same row (identitied by replica identity
key) as another ongoing transaction. If so, the leader sends a list of dependent
transaction IDs to the parallel worker, indicating that the parallel apply
worker must wait for these transactions to commit before proceeding.

Each parallel apply worker records the local end LSN of the transaction it
applies in shared memory. Subsequently, the leader gathers these local end LSNs
and logs them in the local 'lsn_mapping' for verifying whether they have been
flushed to disk (following the logic in get_flush_position()).

If no parallel apply worker is available, the leader will apply the transaction
independently.

For further details, please refer to the following:

--
dedendency tracking
--

The leader maintains a local hash table, using the remote change's replica
identity column values and relid as keys, with remote transaction IDs as values.
Before sending changes to the parallel apply worker, the leader computes a hash
using RI key values and the relid of the current change to search the hash
table. If an existing entry is found, the leader first updates the hash entry
with the receiving remote xid then tells the parallel worker to wait for it.

If the remote relation lacks a replica identity (RI), it indicates that only
INSERT can be replicated for this table. In such cases, the leader skips
dependency checks, allowing the parallel apply worker to proceed with applying
changes without delay. This is because the only potential conflict could happen
is related to the local unique key or foreign key, which that is yet to be
implemented (see TODO - dependency on local unique key, foreign key.).

In cases of TRUNCATE or remote schema changes affecting the entire table, the
leader retrieves all remote xids touching the same table (via sequential scans
of the hash table) and tells the parallel worker to wait for those transactions
to commit.

Hash entries are cleaned up once the transaction corresponding to the remote xid
in the entry has been committed. Clean-up typically occurs when collecting the
flush position of each transaction, but is forced if the hash table exceeds a
set threshold.

--
dedendency waiting
--

If a transaction is relied upon by others, the leader adds its xid to a shared
hash table. The shared hash table entry is cleared by the parallel apply worker
upon completing the transaction. Workers needing to wait for a transaction check
the shared hash table entry; if present, they lock the transaction ID (using
pa_lock_transaction). If absent, it indicates the transaction has been
committed, negating the need to wait.

--
commit order
--

There is a case where columns have no foreign or primary keys, and integrity is
maintained at the application layer. In this case, the above RI mechanism cannot
detect any dependencies. For safety reasons, parallel apply workers preserve the
commit ordering done on the publisher side. This is done by the leader worker
caching the lastly dispatched transaction ID and adding a dependency between it
and the currently dispatching one.

--
TODO - dependency on foreign key.
--

A transaction could conflict with another if modifying the same key.
While current patches don't address conflicts involving foreign keys, tracking
these dependencies might be needed.
---
 .../replication/logical/applyparallelworker.c | 339 ++++++++++++++++--
 src/backend/replication/logical/proto.c       |  38 ++
 src/backend/replication/logical/relation.c    |  31 ++
 src/backend/replication/logical/worker.c      | 303 ++++++++++++++--
 src/include/replication/logicalproto.h        |   2 +
 src/include/replication/logicalrelation.h     |   2 +
 src/include/replication/worker_internal.h     |  11 +-
 src/test/subscription/meson.build             |   1 +
 src/test/subscription/t/001_rep_changes.pl    |   2 +
 src/test/subscription/t/010_truncate.pl       |   2 +-
 src/test/subscription/t/015_stream.pl         |   8 +-
 src/test/subscription/t/026_stats.pl          |   1 +
 src/test/subscription/t/027_nosuperuser.pl    |   1 +
 src/test/subscription/t/050_parallel_apply.pl | 130 +++++++
 src/tools/pgindent/typedefs.list              |   4 +
 15 files changed, 801 insertions(+), 74 deletions(-)
 create mode 100644 src/test/subscription/t/050_parallel_apply.pl

diff --git a/src/backend/replication/logical/applyparallelworker.c b/src/backend/replication/logical/applyparallelworker.c
index cf08206d9fd..5b6267c6047 100644
--- a/src/backend/replication/logical/applyparallelworker.c
+++ b/src/backend/replication/logical/applyparallelworker.c
@@ -14,6 +14,9 @@
  * ParallelApplyWorkerInfo which is required so the leader worker and parallel
  * apply workers can communicate with each other.
  *
+ * Streaming transactions
+ * ======================
+ *
  * The parallel apply workers are assigned (if available) as soon as xact's
  * first stream is received for subscriptions that have set their 'streaming'
  * option as parallel. The leader apply worker will send changes to this new
@@ -152,6 +155,33 @@
  * session-level locks because both locks could be acquired outside the
  * transaction, and the stream lock in the leader needs to persist across
  * transaction boundaries i.e. until the end of the streaming transaction.
+ *
+ * Non-streaming transactions
+ * ======================
+ * The handling is similar to streaming transactions, but including few
+ * differences:
+ *
+ * Transaction dependency
+ * ----------------------
+ * Before dispatching changes to a parallel worker, the leader verifies if the
+ * current modification affects the same row (identitied by replica identity
+ * key) as another ongoing transaction (see handle_dependency_on_change for
+ * details). If so, the leader sends a list of dependent transaction IDs to the
+ * parallel worker, indicating that the parallel apply worker must wait for
+ * these transactions to commit before proceeding.
+ *
+ * Commit order
+ * ------------
+ * There is a case where columns have no foreign or primary keys, and integrity
+ * is maintained at the application layer. In this case, the above RI mechanism
+ * cannot detect any dependencies. For safety reasons, parallel apply workers
+ * preserve the commit ordering done on the publisher side. This is done by the
+ * leader worker caching the lastly dispatched transaction ID and adding a
+ * dependency between it and the currently dispatching one.
+ * We can extend the parallel apply worker to allow out-of-order commits in the
+ * future: At least we must use a new mechanism to track replication progress
+ * in out-of-order commits. Then we can stop caching the transaction ID and
+ * adding the dependency.
  *-------------------------------------------------------------------------
  */
 
@@ -283,6 +313,7 @@ static ParallelTransState pa_get_xact_state(ParallelApplyWorkerShared *wshared);
 static PartialFileSetState pa_get_fileset_state(void);
 static void pa_attach_parallelized_txn_hash(dsa_handle *pa_dsa_handle,
 											dshash_table_handle *pa_dshash_handle);
+static void write_internal_relation(StringInfo s, LogicalRepRelation *rel);
 
 /*
  * Returns true if it is OK to start a parallel apply worker, false otherwise.
@@ -400,6 +431,7 @@ pa_setup_dsm(ParallelApplyWorkerInfo *winfo)
 	shared = shm_toc_allocate(toc, sizeof(ParallelApplyWorkerShared));
 	SpinLockInit(&shared->mutex);
 
+	shared->xid = InvalidTransactionId;
 	shared->xact_state = PARALLEL_TRANS_UNKNOWN;
 	pg_atomic_init_u32(&(shared->pending_stream_count), 0);
 	shared->last_commit_end = InvalidXLogRecPtr;
@@ -443,6 +475,8 @@ pa_launch_parallel_worker(void)
 	MemoryContext oldcontext;
 	bool		launched;
 	ParallelApplyWorkerInfo *winfo;
+	dsa_handle	pa_dsa_handle;
+	dshash_table_handle pa_dshash_handle;
 	ListCell   *lc;
 
 	/* Try to get an available parallel apply worker from the worker pool. */
@@ -450,10 +484,33 @@ pa_launch_parallel_worker(void)
 	{
 		winfo = (ParallelApplyWorkerInfo *) lfirst(lc);
 
-		if (!winfo->in_use)
+		if (!winfo->stream_txn &&
+			pa_get_xact_state(winfo->shared) == PARALLEL_TRANS_FINISHED)
+		{
+			/*
+			 * Save the local commit LSN of the last transaction applied by
+			 * this worker before reusing it for another transaction. This WAL
+			 * position is crucial for determining the flush position in
+			 * responses to the publisher (see get_flush_position()).
+			 */
+			(void) pa_get_last_commit_end(winfo->shared->xid, false, NULL);
+			return winfo;
+		}
+
+		if (winfo->stream_txn && !winfo->in_use)
 			return winfo;
 	}
 
+	pa_attach_parallelized_txn_hash(&pa_dsa_handle, &pa_dshash_handle);
+
+	/*
+	 * Return if the number of parallel apply workers has reached the maximum
+	 * limit.
+	 */
+	if (list_length(ParallelApplyWorkerPool) ==
+		max_parallel_apply_workers_per_subscription)
+		return NULL;
+
 	/*
 	 * Start a new parallel apply worker.
 	 *
@@ -481,18 +538,32 @@ pa_launch_parallel_worker(void)
 										dsm_segment_handle(winfo->dsm_seg),
 										false);
 
-	if (launched)
-	{
-		ParallelApplyWorkerPool = lappend(ParallelApplyWorkerPool, winfo);
-	}
-	else
+	if (!launched)
 	{
+		MemoryContextSwitchTo(oldcontext);
 		pa_free_worker_info(winfo);
-		winfo = NULL;
+		return NULL;
 	}
 
+	ParallelApplyWorkerPool = lappend(ParallelApplyWorkerPool, winfo);
+
 	MemoryContextSwitchTo(oldcontext);
 
+	/*
+	 * Send all existing remote relation information to the parallel apply
+	 * worker. This allows the parallel worker to initialize the
+	 * LogicalRepRelMapEntry locally before applying remote changes.
+	 */
+	if (logicalrep_get_num_rels())
+	{
+		StringInfoData out;
+
+		initStringInfo(&out);
+
+		write_internal_relation(&out, NULL);
+		pa_send_data(winfo, out.len, out.data);
+	}
+
 	return winfo;
 }
 
@@ -597,7 +668,8 @@ pa_free_worker(ParallelApplyWorkerInfo *winfo)
 {
 	Assert(!am_parallel_apply_worker());
 	Assert(winfo->in_use);
-	Assert(pa_get_xact_state(winfo->shared) == PARALLEL_TRANS_FINISHED);
+	Assert(!winfo->stream_txn ||
+		   pa_get_xact_state(winfo->shared) == PARALLEL_TRANS_FINISHED);
 
 	if (!hash_search(ParallelApplyTxnHash, &winfo->shared->xid, HASH_REMOVE, NULL))
 		elog(ERROR, "hash table corrupted");
@@ -613,9 +685,7 @@ pa_free_worker(ParallelApplyWorkerInfo *winfo)
 	 * been serialized and then letting the parallel apply worker deal with
 	 * the spurious message, we stop the worker.
 	 */
-	if (winfo->serialize_changes ||
-		list_length(ParallelApplyWorkerPool) >
-		(max_parallel_apply_workers_per_subscription / 2))
+	if (winfo->serialize_changes)
 	{
 		logicalrep_pa_worker_stop(winfo);
 		pa_free_worker_info(winfo);
@@ -812,6 +882,38 @@ pa_get_last_commit_end(TransactionId xid, bool delete_entry, bool *skipped_write
 	return entry->local_end;
 }
 
+/*
+ * Wait for the remote transaction associated with the specified remote xid to
+ * complete.
+ */
+static void
+pa_wait_for_transaction(TransactionId wait_for_xid)
+{
+	if (!am_leader_apply_worker())
+		return;
+
+	if (!TransactionIdIsValid(wait_for_xid))
+		return;
+
+	elog(DEBUG1, "plan to wait for remote_xid %u to finish",
+		 wait_for_xid);
+
+	for (;;)
+	{
+		if (pa_transaction_committed(wait_for_xid))
+			break;
+
+		pa_lock_transaction(wait_for_xid, AccessShareLock);
+		pa_unlock_transaction(wait_for_xid, AccessShareLock);
+
+		/* An interrupt may have occurred while we were waiting. */
+		CHECK_FOR_INTERRUPTS();
+	}
+
+	elog(DEBUG1, "finished wait for remote_xid %u to finish",
+		 wait_for_xid);
+}
+
 /*
  * Interrupt handler for main loop of parallel apply worker.
  */
@@ -887,21 +989,34 @@ LogicalParallelApplyLoop(shm_mq_handle *mqh)
 			 * parallel apply workers can only be PqReplMsg_WALData.
 			 */
 			c = pq_getmsgbyte(&s);
-			if (c != PqReplMsg_WALData)
-				elog(ERROR, "unexpected message \"%c\"", c);
-
-			/*
-			 * Ignore statistics fields that have been updated by the leader
-			 * apply worker.
-			 *
-			 * XXX We can avoid sending the statistics fields from the leader
-			 * apply worker but for that, it needs to rebuild the entire
-			 * message by removing these fields which could be more work than
-			 * simply ignoring these fields in the parallel apply worker.
-			 */
-			s.cursor += SIZE_STATS_MESSAGE;
+			if (c == PqReplMsg_WALData)
+			{
+				/*
+				 * Ignore statistics fields that have been updated by the
+				 * leader apply worker.
+				 *
+				 * XXX We can avoid sending the statistics fields from the
+				 * leader apply worker but for that, it needs to rebuild the
+				 * entire message by removing these fields which could be more
+				 * work than simply ignoring these fields in the parallel
+				 * apply worker.
+				 */
+				s.cursor += SIZE_STATS_MESSAGE;
 
-			apply_dispatch(&s);
+				apply_dispatch(&s);
+			}
+			else if (c == PARALLEL_APPLY_INTERNAL_MESSAGE)
+			{
+				apply_dispatch(&s);
+			}
+			else
+			{
+				/*
+				 * The first byte of messages sent from leader apply worker to
+				 * parallel apply workers can only be 'w' or 'i'.
+				 */
+				elog(ERROR, "unexpected message \"%c\"", c);
+			}
 		}
 		else if (shmq_res == SHM_MQ_WOULD_BLOCK)
 		{
@@ -918,6 +1033,9 @@ LogicalParallelApplyLoop(shm_mq_handle *mqh)
 
 				if (rc & WL_LATCH_SET)
 					ResetLatch(MyLatch);
+
+				if (!IsTransactionState())
+					pgstat_report_stat(true);
 			}
 		}
 		else
@@ -955,6 +1073,9 @@ pa_shutdown(int code, Datum arg)
 				   INVALID_PROC_NUMBER);
 
 	dsm_detach((dsm_segment *) DatumGetPointer(arg));
+
+	if (parallel_apply_dsa_area)
+		dsa_detach(parallel_apply_dsa_area);
 }
 
 /*
@@ -1267,7 +1388,6 @@ pa_send_data(ParallelApplyWorkerInfo *winfo, Size nbytes, const void *data)
 	shm_mq_result result;
 	TimestampTz startTime = 0;
 
-	Assert(!IsTransactionState());
 	Assert(!winfo->serialize_changes);
 
 	/*
@@ -1319,6 +1439,67 @@ pa_send_data(ParallelApplyWorkerInfo *winfo, Size nbytes, const void *data)
 	}
 }
 
+/*
+ * Distribute remote relation information to all active parallel apply workers
+ * that require it.
+ */
+void
+pa_distribute_schema_changes_to_workers(LogicalRepRelation *rel)
+{
+	List	   *workers_stopped = NIL;
+	StringInfoData out;
+
+	if (!ParallelApplyWorkerPool)
+		return;
+
+	initStringInfo(&out);
+
+	write_internal_relation(&out, rel);
+
+	foreach_ptr(ParallelApplyWorkerInfo, winfo, ParallelApplyWorkerPool)
+	{
+		/*
+		 * Skip the worker responsible for the current transaction, as the
+		 * relation information has already been sent to it.
+		 */
+		if (winfo == stream_apply_worker)
+			continue;
+
+		/*
+		 * Skip the worker that is in serialize mode, as they will soon stop
+		 * once they finish applying the transaction.
+		 */
+		if (winfo->serialize_changes)
+			continue;
+
+		elog(DEBUG1, "distributing schema changes to pa workers");
+
+		if (pa_send_data(winfo, out.len, out.data))
+			continue;
+
+		elog(DEBUG1, "failed to distribute, will stop that worker instead");
+
+		/*
+		 * Distribution to this worker failed due to a sending timeout. Wait
+		 * for the worker to complete its transaction and then stop it. This
+		 * is consistent with the handling of workers in serialize mode (see
+		 * pa_free_worker() for details).
+		 */
+		pa_wait_for_transaction(winfo->shared->xid);
+
+		pa_get_last_commit_end(winfo->shared->xid, false, NULL);
+
+		logicalrep_pa_worker_stop(winfo);
+
+		workers_stopped = lappend(workers_stopped, winfo);
+	}
+
+	pfree(out.data);
+
+	foreach_ptr(ParallelApplyWorkerInfo, winfo, workers_stopped)
+		pa_free_worker_info(winfo);
+}
+
 /*
  * Switch to PARTIAL_SERIALIZE mode for the current transaction -- this means
  * that the current data and any subsequent data for this transaction will be
@@ -1401,8 +1582,8 @@ pa_wait_for_xact_finish(ParallelApplyWorkerInfo *winfo)
 
 	/*
 	 * Wait for the transaction lock to be released. This is required to
-	 * detect deadlock among leader and parallel apply workers. Refer to the
-	 * comments atop this file.
+	 * detect detect deadlock among leader and parallel apply workers. Refer
+	 * to the comments atop this file.
 	 */
 	pa_lock_transaction(winfo->shared->xid, AccessShareLock);
 	pa_unlock_transaction(winfo->shared->xid, AccessShareLock);
@@ -1479,6 +1660,9 @@ pa_savepoint_name(Oid suboid, TransactionId xid, char *spname, Size szsp)
 void
 pa_start_subtrans(TransactionId current_xid, TransactionId top_xid)
 {
+	if (!TransactionIdIsValid(top_xid))
+		return;
+
 	if (current_xid != top_xid &&
 		!list_member_xid(subxactlist, current_xid))
 	{
@@ -1735,25 +1919,41 @@ pa_decr_and_wait_stream_block(void)
 void
 pa_xact_finish(ParallelApplyWorkerInfo *winfo, XLogRecPtr remote_lsn)
 {
+	XLogRecPtr	local_lsn = InvalidXLogRecPtr;
+	TransactionId pa_remote_xid = winfo->shared->xid;
+
 	Assert(am_leader_apply_worker());
 
 	/*
-	 * Unlock the shared object lock so that parallel apply worker can
-	 * continue to receive and apply changes.
+	 * Unlock the shared object lock taken for streaming transactions so that
+	 * parallel apply worker can continue to receive and apply changes.
 	 */
-	pa_unlock_stream(winfo->shared->xid, AccessExclusiveLock);
+	if (winfo->stream_txn)
+		pa_unlock_stream(winfo->shared->xid, AccessExclusiveLock);
 
 	/*
-	 * Wait for that worker to finish. This is necessary to maintain commit
-	 * order which avoids failures due to transaction dependencies and
-	 * deadlocks.
+	 * Wait for that worker for streaming transaction to finish. This is
+	 * necessary to maintain commit order which avoids failures due to
+	 * transaction dependencies and deadlocks.
+	 *
+	 * For non-streaming transaction but in partial seralize mode, wait for
+	 * stop as well as the worker is anyway cannot be reused anymore (see
+	 * pa_free_worker() for details).
 	 */
-	pa_wait_for_xact_finish(winfo);
+	if (winfo->serialize_changes || winfo->stream_txn)
+	{
+		pa_wait_for_xact_finish(winfo);
+
+		local_lsn = winfo->shared->last_commit_end;
+		pa_remote_xid = InvalidTransactionId;
+
+		pa_free_worker(winfo);
+	}
 
 	if (XLogRecPtrIsValid(remote_lsn))
-		store_flush_position(remote_lsn, winfo->shared->last_commit_end);
+		store_flush_position(remote_lsn, local_lsn, pa_remote_xid);
 
-	pa_free_worker(winfo);
+	pa_set_stream_apply_worker(NULL);
 }
 
 bool
@@ -1852,6 +2052,22 @@ pa_record_dependency_on_transactions(List *depends_on_xids)
 	}
 }
 
+/*
+ * Mark the transaction state as finished and remove the shared hash entry.
+ */
+void
+pa_commit_transaction(void)
+{
+	TransactionId xid = MyParallelShared->xid;
+
+	SpinLockAcquire(&MyParallelShared->mutex);
+	MyParallelShared->xact_state = PARALLEL_TRANS_FINISHED;
+	SpinLockRelease(&MyParallelShared->mutex);
+
+	dshash_delete_key(parallelized_txns, &xid);
+	elog(DEBUG1, "depended xid %u committed", xid);
+}
+
 /*
  * Wait for the given transaction to finish.
  */
@@ -1860,6 +2076,13 @@ pa_wait_for_depended_transaction(TransactionId xid)
 {
 	elog(DEBUG1, "wait for depended xid %u", xid);
 
+	/*
+	 * Quick exit if parallelized_txns has not been initialized yet. This can
+	 * happen when this function is called by the leader worker.
+	 */
+	if (!parallelized_txns)
+		return;
+
 	for (;;)
 	{
 		ParallelizedTxnEntry *txn_entry;
@@ -1880,3 +2103,45 @@ pa_wait_for_depended_transaction(TransactionId xid)
 
 	elog(DEBUG1, "finish waiting for depended xid %u", xid);
 }
+
+/*
+ * Write internal relation description to the output stream.
+ */
+static void
+write_internal_relation(StringInfo s, LogicalRepRelation *rel)
+{
+	pq_sendbyte(s, PARALLEL_APPLY_INTERNAL_MESSAGE);
+	pq_sendbyte(s, LOGICAL_REP_MSG_INTERNAL_RELATION);
+
+	if (rel)
+	{
+		pq_sendint(s, 1, 4);
+		logicalrep_write_internal_rel(s, rel);
+	}
+	else
+	{
+		pq_sendint(s, logicalrep_get_num_rels(), 4);
+		logicalrep_write_all_rels(s);
+	}
+}
+
+/*
+ * Register a transaction to the shared hash table.
+ *
+ * This function is intended to be called during the commit phase of
+ * non-streamed transactions. Other parallel workers would wait,
+ * removing the added entry.
+ */
+void
+pa_add_parallelized_transaction(TransactionId xid)
+{
+	bool		found;
+	ParallelizedTxnEntry *txn_entry;
+
+	Assert(parallelized_txns);
+	Assert(TransactionIdIsValid(xid));
+
+	txn_entry = dshash_find_or_insert(parallelized_txns, &xid, &found);
+
+	dshash_release_lock(parallelized_txns, txn_entry);
+}
diff --git a/src/backend/replication/logical/proto.c b/src/backend/replication/logical/proto.c
index ded46c49a83..96b6a74055e 100644
--- a/src/backend/replication/logical/proto.c
+++ b/src/backend/replication/logical/proto.c
@@ -691,6 +691,44 @@ logicalrep_write_rel(StringInfo out, TransactionId xid, Relation rel,
 	logicalrep_write_attrs(out, rel, columns, include_gencols_type);
 }
 
+/*
+ * Write internal relation description to the output stream.
+ */
+void
+logicalrep_write_internal_rel(StringInfo out, LogicalRepRelation *rel)
+{
+	pq_sendint32(out, rel->remoteid);
+
+	/* Write relation name */
+	pq_sendstring(out, rel->nspname);
+	pq_sendstring(out, rel->relname);
+
+	/* Write the replica identity. */
+	pq_sendbyte(out, rel->replident);
+
+	/* Write attribute description */
+	pq_sendint16(out, rel->natts);
+
+	for (int i = 0; i < rel->natts; i++)
+	{
+		uint8		flags = 0;
+
+		if (bms_is_member(i, rel->attkeys))
+			flags |= LOGICALREP_IS_REPLICA_IDENTITY;
+
+		pq_sendbyte(out, flags);
+
+		/* attribute name */
+		pq_sendstring(out, rel->attnames[i]);
+
+		/* attribute type id */
+		pq_sendint32(out, rel->atttyps[i]);
+
+		/* ignore attribute mode for now */
+		pq_sendint32(out, 0);
+	}
+}
+
 /*
  * Read the relation info from stream and return as LogicalRepRelation.
  */
diff --git a/src/backend/replication/logical/relation.c b/src/backend/replication/logical/relation.c
index 13f8cb74e9f..9991bfe76cc 100644
--- a/src/backend/replication/logical/relation.c
+++ b/src/backend/replication/logical/relation.c
@@ -960,6 +960,37 @@ FindLogicalRepLocalIndex(Relation localrel, LogicalRepRelation *remoterel,
 	return InvalidOid;
 }
 
+/*
+ * Get the number of entries in the LogicalRepRelMap.
+ */
+int
+logicalrep_get_num_rels(void)
+{
+	if (LogicalRepRelMap == NULL)
+		return 0;
+
+	return hash_get_num_entries(LogicalRepRelMap);
+}
+
+/*
+ * Write all the remote relation information from the LogicalRepRelMapEntry to
+ * the output stream.
+ */
+void
+logicalrep_write_all_rels(StringInfo out)
+{
+	LogicalRepRelMapEntry *entry;
+	HASH_SEQ_STATUS status;
+
+	if (LogicalRepRelMap == NULL)
+		return;
+
+	hash_seq_init(&status, LogicalRepRelMap);
+
+	while ((entry = (LogicalRepRelMapEntry *) hash_seq_search(&status)) != NULL)
+		logicalrep_write_internal_rel(out, &entry->remoterel);
+}
+
 /*
  * Get the LogicalRepRelMapEntry corresponding to the given relid without
  * opening the local relation.
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 0b1eeefe9c9..3832481647e 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -286,6 +286,7 @@
 #include "tcop/tcopprot.h"
 #include "utils/acl.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/lsyscache.h"
 #include "utils/memutils.h"
@@ -484,6 +485,8 @@ static List *on_commit_wakeup_workers_subids = NIL;
 
 bool		in_remote_transaction = false;
 static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+static TransactionId remote_xid = InvalidTransactionId;
+static TransactionId last_remote_xid = InvalidTransactionId;
 
 /* fields valid only when processing streamed transaction */
 static bool in_streamed_transaction = false;
@@ -602,11 +605,7 @@ static inline void cleanup_subxact_info(void);
 /*
  * Serialize and deserialize changes for a toplevel transaction.
  */
-static void stream_open_file(Oid subid, TransactionId xid,
-							 bool first_segment);
 static void stream_write_change(char action, StringInfo s);
-static void stream_open_and_write_change(TransactionId xid, char action, StringInfo s);
-static void stream_close_file(void);
 
 static void send_feedback(XLogRecPtr recvpos, bool force, bool requestReply);
 
@@ -676,6 +675,8 @@ static void replorigin_reset(int code, Datum arg);
 static bool send_internal_dependencies(ParallelApplyWorkerInfo *winfo,
 									   StringInfo s);
 
+static bool build_dependency_with_last_committed_txn(ParallelApplyWorkerInfo *winfo);
+
 /*
  * Compute the hash value for entries in the replica_identity_table.
  */
@@ -1406,7 +1407,11 @@ handle_streamed_transaction(LogicalRepMsgType action, StringInfo s)
 	TransApplyAction apply_action;
 	StringInfoData original_msg;
 
-	apply_action = get_transaction_apply_action(stream_xid, &winfo);
+	Assert(!in_streamed_transaction || TransactionIdIsValid(stream_xid));
+
+	apply_action = get_transaction_apply_action(in_streamed_transaction
+												? stream_xid : remote_xid,
+												&winfo);
 
 	/* not in streaming mode */
 	if (apply_action == TRANS_LEADER_APPLY)
@@ -1415,8 +1420,6 @@ handle_streamed_transaction(LogicalRepMsgType action, StringInfo s)
 		return false;
 	}
 
-	Assert(TransactionIdIsValid(stream_xid));
-
 	/*
 	 * The parallel apply worker needs the xid in this message to decide
 	 * whether to define a savepoint, so save the original message that has
@@ -1427,15 +1430,28 @@ handle_streamed_transaction(LogicalRepMsgType action, StringInfo s)
 
 	/*
 	 * We should have received XID of the subxact as the first part of the
-	 * message, so extract it.
+	 * message in streaming transactions, so extract it.
 	 */
-	current_xid = pq_getmsgint(s, 4);
+	if (in_streamed_transaction)
+		current_xid = pq_getmsgint(s, 4);
+	else
+		current_xid = remote_xid;
 
 	if (!TransactionIdIsValid(current_xid))
 		ereport(ERROR,
 				(errcode(ERRCODE_PROTOCOL_VIOLATION),
 				 errmsg_internal("invalid transaction ID in streamed replication transaction")));
 
+	handle_dependency_on_change(action, s, current_xid, winfo);
+
+	/*
+	 * Re-fetch the latest apply action as it might have been changed during
+	 * dependency check.
+	 */
+	apply_action = get_transaction_apply_action(in_streamed_transaction
+												? stream_xid : remote_xid,
+												&winfo);
+
 	switch (apply_action)
 	{
 		case TRANS_LEADER_SERIALIZE:
@@ -1839,17 +1855,71 @@ static void
 apply_handle_begin(StringInfo s)
 {
 	LogicalRepBeginData begin_data;
+	ParallelApplyWorkerInfo *winfo;
+	TransApplyAction apply_action;
+
+	/* Save the message before it is consumed. */
+	StringInfoData original_msg = *s;
 
 	/* There must not be an active streaming transaction. */
 	Assert(!TransactionIdIsValid(stream_xid));
 
 	logicalrep_read_begin(s, &begin_data);
-	set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
+
+	remote_xid = begin_data.xid;
+
+	set_apply_error_context_xact(remote_xid, begin_data.final_lsn);
 
 	remote_final_lsn = begin_data.final_lsn;
 
 	maybe_start_skipping_changes(begin_data.final_lsn);
 
+	pa_allocate_worker(remote_xid, false);
+
+	apply_action = get_transaction_apply_action(remote_xid, &winfo);
+
+	elog(DEBUG1, "new remote_xid %u", remote_xid);
+	switch (apply_action)
+	{
+		case TRANS_LEADER_APPLY:
+			break;
+
+		case TRANS_LEADER_SEND_TO_PARALLEL:
+			Assert(winfo);
+
+			if (pa_send_data(winfo, s->len, s->data))
+			{
+				pa_set_stream_apply_worker(winfo);
+				break;
+			}
+
+			/*
+			 * Switch to serialize mode when we are not able to send the
+			 * change to parallel apply worker.
+			 */
+			pa_switch_to_partial_serialize(winfo, true);
+
+/* fall through */
+		case TRANS_LEADER_PARTIAL_SERIALIZE:
+			Assert(winfo);
+
+			stream_write_change(LOGICAL_REP_MSG_BEGIN, &original_msg);
+
+			/* Cache the parallel apply worker for this transaction. */
+			pa_set_stream_apply_worker(winfo);
+			break;
+
+		case TRANS_PARALLEL_APPLY:
+			/* Hold the lock until the end of the transaction. */
+			pa_lock_transaction(MyParallelShared->xid, AccessExclusiveLock);
+			pa_set_xact_state(MyParallelShared, PARALLEL_TRANS_STARTED);
+			break;
+
+		default:
+			elog(ERROR, "unexpected apply action: %d", (int) apply_action);
+			break;
+	}
+
 	in_remote_transaction = true;
 
 	pgstat_report_activity(STATE_RUNNING, NULL);
@@ -1882,6 +1952,37 @@ send_internal_dependencies(ParallelApplyWorkerInfo *winfo, StringInfo s)
 	return false;
 }
 
+/*
+ * Make a dependency between this and the lastly committed transaction.
+ *
+ * This function ensures that the commit ordering handled by parallel apply
+ * workers is preserved. Returns false if we switched to the serialize mode to
+ * send the massage, true otherwise.
+ */
+static bool
+build_dependency_with_last_committed_txn(ParallelApplyWorkerInfo *winfo)
+{
+	StringInfoData dependency_msg;
+	bool		ret;
+
+	/* Skip if transactions have not been applied yet */
+	if (!TransactionIdIsValid(last_remote_xid))
+		return true;
+
+	/* Build the dependency message used to send to parallel apply worker */
+	initStringInfo(&dependency_msg);
+
+	pq_sendbyte(&dependency_msg, PARALLEL_APPLY_INTERNAL_MESSAGE);
+	pq_sendbyte(&dependency_msg, LOGICAL_REP_MSG_INTERNAL_DEPENDENCY);
+	pq_sendint32(&dependency_msg, 1);
+	pq_sendint32(&dependency_msg, last_remote_xid);
+
+	ret = send_internal_dependencies(winfo, &dependency_msg);
+
+	pfree(dependency_msg.data);
+	return ret;
+}
+
 /*
  * Handle COMMIT message.
  *
@@ -1891,6 +1992,11 @@ static void
 apply_handle_commit(StringInfo s)
 {
 	LogicalRepCommitData commit_data;
+	ParallelApplyWorkerInfo *winfo;
+	TransApplyAction apply_action;
+
+	/* Save the message before it is consumed. */
+	StringInfoData original_msg = *s;
 
 	logicalrep_read_commit(s, &commit_data);
 
@@ -1901,7 +2007,97 @@ apply_handle_commit(StringInfo s)
 								 LSN_FORMAT_ARGS(commit_data.commit_lsn),
 								 LSN_FORMAT_ARGS(remote_final_lsn))));
 
-	apply_handle_commit_internal(&commit_data);
+	apply_action = get_transaction_apply_action(remote_xid, &winfo);
+
+	switch (apply_action)
+	{
+		case TRANS_LEADER_APPLY:
+			/*
+			 * Apart from parallelized transactions, we do not have to register
+			 * this transaction to parallelized_txns. The commit ordering is
+			 * always preserved.
+			 */
+
+			/* Wait until the last transaction finishes */
+			if (TransactionIdIsValid(last_remote_xid))
+				pa_wait_for_depended_transaction(last_remote_xid);
+
+			apply_handle_commit_internal(&commit_data);
+
+			break;
+
+		case TRANS_LEADER_SEND_TO_PARALLEL:
+			Assert(winfo);
+
+			/*
+			 * Mark this transaction as parallelized. This ensures that
+			 * upcoming transactions wait until this transaction is committed.
+			 */
+			pa_add_parallelized_transaction(remote_xid);
+
+			/*
+			 * Build a dependency between this transaction and the lastly
+			 * committed transaction to preserve the commit order. Then try to
+			 * send a COMMIT message if succeeded.
+			 */
+			if (build_dependency_with_last_committed_txn(winfo) &&
+				pa_send_data(winfo, s->len, s->data))
+			{
+				/* Finish processing the transaction. */
+				pa_xact_finish(winfo, commit_data.end_lsn);
+				break;
+			}
+
+			/*
+			 * Switch to serialize mode when we are not able to send the
+			 * change to parallel apply worker.
+			 */
+			pa_switch_to_partial_serialize(winfo, true);
+/* fall through */
+		case TRANS_LEADER_PARTIAL_SERIALIZE:
+			Assert(winfo);
+
+			stream_open_and_write_change(remote_xid, LOGICAL_REP_MSG_COMMIT,
+										 &original_msg);
+
+			pa_set_fileset_state(winfo->shared, FS_SERIALIZE_DONE);
+
+			/* Finish processing the transaction. */
+			pa_xact_finish(winfo, commit_data.end_lsn);
+			break;
+
+		case TRANS_PARALLEL_APPLY:
+
+			/*
+			 * If the parallel apply worker is applying spooled messages then
+			 * close the file before committing.
+			 */
+			if (stream_fd)
+				stream_close_file();
+
+			INJECTION_POINT("parallel-worker-before-commit", NULL);
+
+			apply_handle_commit_internal(&commit_data);
+
+			MyParallelShared->last_commit_end = XactLastCommitEnd;
+
+			pa_commit_transaction();
+
+			pa_unlock_transaction(remote_xid, AccessExclusiveLock);
+			break;
+
+		default:
+			elog(ERROR, "unexpected apply action: %d", (int) apply_action);
+			break;
+	}
+
+	/* Cache the remote_xid */
+	last_remote_xid = remote_xid;
+
+	remote_xid = InvalidTransactionId;
+	in_remote_transaction = false;
+
+	elog(DEBUG1, "reset remote_xid %u", remote_xid);
 
 	/*
 	 * Process any tables that are being synchronized in parallel, as well as
@@ -2024,7 +2220,8 @@ apply_handle_prepare(StringInfo s)
 	 * XactLastCommitEnd, and adding it for this purpose doesn't seems worth
 	 * it.
 	 */
-	store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr);
+	store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr,
+						 InvalidTransactionId);
 
 	in_remote_transaction = false;
 
@@ -2084,7 +2281,8 @@ apply_handle_commit_prepared(StringInfo s)
 	CommitTransactionCommand();
 	pgstat_report_stat(false);
 
-	store_flush_position(prepare_data.end_lsn, XactLastCommitEnd);
+	store_flush_position(prepare_data.end_lsn, XactLastCommitEnd,
+						 InvalidTransactionId);
 	in_remote_transaction = false;
 
 	/*
@@ -2153,7 +2351,8 @@ apply_handle_rollback_prepared(StringInfo s)
 	 * transaction because we always flush the WAL record for it. See
 	 * apply_handle_prepare.
 	 */
-	store_flush_position(rollback_data.rollback_end_lsn, InvalidXLogRecPtr);
+	store_flush_position(rollback_data.rollback_end_lsn, InvalidXLogRecPtr,
+						 InvalidTransactionId);
 	in_remote_transaction = false;
 
 	/*
@@ -2215,7 +2414,8 @@ apply_handle_stream_prepare(StringInfo s)
 			 * It is okay not to set the local_end LSN for the prepare because
 			 * we always flush the prepare record. See apply_handle_prepare.
 			 */
-			store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr);
+			store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr,
+								 InvalidTransactionId);
 
 			in_remote_transaction = false;
 
@@ -2467,6 +2667,11 @@ apply_handle_stream_start(StringInfo s)
 		case TRANS_LEADER_PARTIAL_SERIALIZE:
 			Assert(winfo);
 
+			/*
+			 * TODO, the pa worker could start to wait too soon when
+			 * processing some old stream start
+			 */
+
 			/*
 			 * Open the spool file unless it was already opened when switching
 			 * to serialize mode. The transaction started in
@@ -3194,7 +3399,8 @@ apply_handle_commit_internal(LogicalRepCommitData *commit_data)
 
 		pgstat_report_stat(false);
 
-		store_flush_position(commit_data->end_lsn, XactLastCommitEnd);
+		store_flush_position(commit_data->end_lsn, XactLastCommitEnd,
+							 InvalidTransactionId);
 	}
 	else
 	{
@@ -3227,6 +3433,9 @@ apply_handle_relation(StringInfo s)
 
 	/* Also reset all entries in the partition map that refer to remoterel. */
 	logicalrep_partmap_reset_relmap(rel);
+
+	if (am_leader_apply_worker())
+		pa_distribute_schema_changes_to_workers(rel);
 }
 
 /*
@@ -4001,6 +4210,8 @@ FindDeletedTupleInLocalRel(Relation localrel, Oid localidxoid,
 
 /*
  * This handles insert, update, delete on a partitioned table.
+ *
+ * TODO, support parallel apply.
  */
 static void
 apply_handle_tuple_routing(ApplyExecutionData *edata,
@@ -4551,6 +4762,10 @@ apply_dispatch(StringInfo s)
  * check which entries on it are already locally flushed. Those we can report
  * as having been flushed.
  *
+ * For non-streaming transactions managed by a parallel apply worker, we will
+ * get the local commit end from the shared parallel apply worker info once the
+ * transaction has been committed by the worker.
+ *
  * The have_pending_txes is true if there are outstanding transactions that
  * need to be flushed.
  */
@@ -4560,6 +4775,7 @@ get_flush_position(XLogRecPtr *write, XLogRecPtr *flush,
 {
 	dlist_mutable_iter iter;
 	XLogRecPtr	local_flush = GetFlushRecPtr(NULL);
+	List	   *committed_pa_xid = NIL;
 
 	*write = InvalidXLogRecPtr;
 	*flush = InvalidXLogRecPtr;
@@ -4569,6 +4785,36 @@ get_flush_position(XLogRecPtr *write, XLogRecPtr *flush,
 		FlushPosition *pos =
 			dlist_container(FlushPosition, node, iter.cur);
 
+		if (TransactionIdIsValid(pos->pa_remote_xid) &&
+			XLogRecPtrIsInvalid(pos->local_end))
+		{
+			bool		skipped_write;
+
+			pos->local_end = pa_get_last_commit_end(pos->pa_remote_xid, true,
+													&skipped_write);
+
+			elog(DEBUG1,
+				 "got commit end from parallel apply worker, "
+				 "txn: %u, remote_end %X/%X, local_end %X/%X",
+				 pos->pa_remote_xid, LSN_FORMAT_ARGS(pos->remote_end),
+				 LSN_FORMAT_ARGS(pos->local_end));
+
+			/*
+			 * Break the loop if the worker has not finished applying the
+			 * transaction. There's no need to check subsequent transactions,
+			 * as they must commit after the current transaction being
+			 * examined and thus won't have their commit end available yet.
+			 */
+			if (!skipped_write && XLogRecPtrIsInvalid(pos->local_end))
+				break;
+
+			committed_pa_xid = lappend_xid(committed_pa_xid, pos->pa_remote_xid);
+		}
+
+		/*
+		 * Worker has finished applying or the transaction was applied in the
+		 * leader apply worker
+		 */
 		*write = pos->remote_end;
 
 		if (pos->local_end <= local_flush)
@@ -4577,29 +4823,19 @@ get_flush_position(XLogRecPtr *write, XLogRecPtr *flush,
 			dlist_delete(iter.cur);
 			pfree(pos);
 		}
-		else
-		{
-			/*
-			 * Don't want to uselessly iterate over the rest of the list which
-			 * could potentially be long. Instead get the last element and
-			 * grab the write position from there.
-			 */
-			pos = dlist_tail_element(FlushPosition, node,
-									 &lsn_mapping);
-			*write = pos->remote_end;
-			*have_pending_txes = true;
-			return;
-		}
 	}
 
 	*have_pending_txes = !dlist_is_empty(&lsn_mapping);
+
+	cleanup_replica_identity_table(committed_pa_xid);
 }
 
 /*
  * Store current remote/local lsn pair in the tracking list.
  */
 void
-store_flush_position(XLogRecPtr remote_lsn, XLogRecPtr local_lsn)
+store_flush_position(XLogRecPtr remote_lsn, XLogRecPtr local_lsn,
+					 TransactionId remote_xid)
 {
 	FlushPosition *flushpos;
 
@@ -4617,6 +4853,7 @@ store_flush_position(XLogRecPtr remote_lsn, XLogRecPtr local_lsn)
 	flushpos = palloc_object(FlushPosition);
 	flushpos->local_end = local_lsn;
 	flushpos->remote_end = remote_lsn;
+	flushpos->pa_remote_xid = remote_xid;
 
 	dlist_push_tail(&lsn_mapping, &flushpos->node);
 	MemoryContextSwitchTo(ApplyMessageContext);
@@ -6064,7 +6301,7 @@ stream_cleanup_files(Oid subid, TransactionId xid)
  * changes for this transaction, create the buffile, otherwise open the
  * previously created file.
  */
-static void
+void
 stream_open_file(Oid subid, TransactionId xid, bool first_segment)
 {
 	char		path[MAXPGPATH];
@@ -6109,7 +6346,7 @@ stream_open_file(Oid subid, TransactionId xid, bool first_segment)
  * stream_close_file
  *	  Close the currently open file with streamed changes.
  */
-static void
+void
 stream_close_file(void)
 {
 	Assert(stream_fd != NULL);
@@ -6157,7 +6394,7 @@ stream_write_change(char action, StringInfo s)
  * target file if not already before writing the message and close the file at
  * the end.
  */
-static void
+void
 stream_open_and_write_change(TransactionId xid, char action, StringInfo s)
 {
 	Assert(!in_streamed_transaction);
diff --git a/src/include/replication/logicalproto.h b/src/include/replication/logicalproto.h
index 5d91e2a4287..7d2aaf2d389 100644
--- a/src/include/replication/logicalproto.h
+++ b/src/include/replication/logicalproto.h
@@ -253,6 +253,8 @@ extern void logicalrep_write_message(StringInfo out, TransactionId xid, XLogRecP
 extern void logicalrep_write_rel(StringInfo out, TransactionId xid,
 								 Relation rel, Bitmapset *columns,
 								 PublishGencolsType include_gencols_type);
+extern void logicalrep_write_internal_rel(StringInfo out,
+										  LogicalRepRelation *rel);
 extern LogicalRepRelation *logicalrep_read_rel(StringInfo in);
 extern void logicalrep_write_typ(StringInfo out, TransactionId xid,
 								 Oid typoid);
diff --git a/src/include/replication/logicalrelation.h b/src/include/replication/logicalrelation.h
index 4b321bd2ad2..34a7069e9e5 100644
--- a/src/include/replication/logicalrelation.h
+++ b/src/include/replication/logicalrelation.h
@@ -52,6 +52,8 @@ extern void logicalrep_rel_close(LogicalRepRelMapEntry *rel,
 								 LOCKMODE lockmode);
 extern bool IsIndexUsableForReplicaIdentityFull(Relation idxrel, AttrMap *attrmap);
 extern Oid	GetRelationIdentityOrPK(Relation rel);
+extern int	logicalrep_get_num_rels(void);
+extern void logicalrep_write_all_rels(StringInfo out);
 extern LogicalRepRelMapEntry *logicalrep_get_relentry(LogicalRepRelId remoteid);
 
 #endif							/* LOGICALRELATION_H */
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index 78b5667cebe..5371ee767f1 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -314,6 +314,10 @@ extern void apply_dispatch(StringInfo s);
 extern void maybe_reread_subscription(void);
 
 extern void stream_cleanup_files(Oid subid, TransactionId xid);
+extern void stream_open_file(Oid subid, TransactionId xid, bool first_segment);
+extern void stream_close_file(void);
+extern void stream_open_and_write_change(TransactionId xid, char action,
+										 StringInfo s);
 
 extern void set_stream_options(WalRcvStreamOptions *options,
 							   char *slotname,
@@ -327,7 +331,8 @@ extern void SetupApplyOrSyncWorker(int worker_slot);
 
 extern void DisableSubscriptionAndExit(void);
 
-extern void store_flush_position(XLogRecPtr remote_lsn, XLogRecPtr local_lsn);
+extern void store_flush_position(XLogRecPtr remote_lsn, XLogRecPtr local_lsn,
+								 TransactionId remote_xid);
 
 /* Function for apply error callback */
 extern void apply_error_callback(void *arg);
@@ -342,6 +347,7 @@ extern void pa_detach_all_error_mq(void);
 
 extern bool pa_send_data(ParallelApplyWorkerInfo *winfo, Size nbytes,
 						 const void *data);
+extern void pa_distribute_schema_changes_to_workers(LogicalRepRelation *rel);
 extern void pa_switch_to_partial_serialize(ParallelApplyWorkerInfo *winfo,
 										   bool stream_locked);
 
@@ -368,8 +374,9 @@ extern void pa_xact_finish(ParallelApplyWorkerInfo *winfo,
 						   XLogRecPtr remote_lsn);
 extern bool pa_transaction_committed(TransactionId xid);
 extern void pa_record_dependency_on_transactions(List *depends_on_xids);
-
+extern void pa_commit_transaction(void);
 extern void pa_wait_for_depended_transaction(TransactionId xid);
+extern void pa_add_parallelized_transaction(TransactionId xid);
 
 #define isParallelApplyWorker(worker) ((worker)->in_use && \
 									   (worker)->type == WORKERTYPE_PARALLEL_APPLY)
diff --git a/src/test/subscription/meson.build b/src/test/subscription/meson.build
index 85d10a89994..e877ca09c30 100644
--- a/src/test/subscription/meson.build
+++ b/src/test/subscription/meson.build
@@ -46,6 +46,7 @@ tests += {
       't/034_temporal.pl',
       't/035_conflicts.pl',
       't/036_sequences.pl',
+      't/050_parallel_apply.pl',
       't/100_bugs.pl',
     ],
   },
diff --git a/src/test/subscription/t/001_rep_changes.pl b/src/test/subscription/t/001_rep_changes.pl
index ecb79e79474..0ccec516a18 100644
--- a/src/test/subscription/t/001_rep_changes.pl
+++ b/src/test/subscription/t/001_rep_changes.pl
@@ -16,6 +16,8 @@ $node_publisher->start;
 # Create subscriber node
 my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
 $node_subscriber->init;
+$node_subscriber->append_conf('postgresql.conf',
+	"max_logical_replication_workers = 10");
 $node_subscriber->start;
 
 # Create some preexisting content on publisher
diff --git a/src/test/subscription/t/010_truncate.pl b/src/test/subscription/t/010_truncate.pl
index 3d16c2a800d..c2fba0b9a9c 100644
--- a/src/test/subscription/t/010_truncate.pl
+++ b/src/test/subscription/t/010_truncate.pl
@@ -17,7 +17,7 @@ $node_publisher->start;
 my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
 $node_subscriber->init;
 $node_subscriber->append_conf('postgresql.conf',
-	qq(max_logical_replication_workers = 6));
+	qq(max_logical_replication_workers = 7));
 $node_subscriber->start;
 
 my $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
diff --git a/src/test/subscription/t/015_stream.pl b/src/test/subscription/t/015_stream.pl
index 03135b1cd6e..e79ddd9a41c 100644
--- a/src/test/subscription/t/015_stream.pl
+++ b/src/test/subscription/t/015_stream.pl
@@ -232,6 +232,12 @@ $node_subscriber->wait_for_log(
 
 $node_publisher->safe_psql('postgres', "INSERT INTO test_tab_2 values(1)");
 
+# FIXME: Currently, non-streaming transactions are applied in parallel by
+# default. So, the first transaction is handled by a parallel apply worker. To
+# trigger the deadlock, initiate an more transaction to be applied by the
+# leader.
+$node_publisher->safe_psql('postgres', "INSERT INTO test_tab_2 values(1)");
+
 $h->query_safe('COMMIT');
 $h->quit;
 
@@ -247,7 +253,7 @@ $node_publisher->wait_for_catchup($appname);
 
 $result =
   $node_subscriber->safe_psql('postgres', "SELECT count(*) FROM test_tab_2");
-is($result, qq(5001), 'data replicated to subscriber after dropping index');
+is($result, qq(5002), 'data replicated to subscriber after dropping index');
 
 # Clean up test data from the environment.
 $node_publisher->safe_psql('postgres', "TRUNCATE TABLE test_tab_2");
diff --git a/src/test/subscription/t/026_stats.pl b/src/test/subscription/t/026_stats.pl
index a430ab4feec..58e34839ab4 100644
--- a/src/test/subscription/t/026_stats.pl
+++ b/src/test/subscription/t/026_stats.pl
@@ -16,6 +16,7 @@ $node_publisher->start;
 # Create subscriber node.
 my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
 $node_subscriber->init;
+$node_subscriber->append_conf('postgresql.conf', "max_logical_replication_workers = 10");
 $node_subscriber->start;
 
 
diff --git a/src/test/subscription/t/027_nosuperuser.pl b/src/test/subscription/t/027_nosuperuser.pl
index 691731743df..e0c1d213800 100644
--- a/src/test/subscription/t/027_nosuperuser.pl
+++ b/src/test/subscription/t/027_nosuperuser.pl
@@ -86,6 +86,7 @@ $node_publisher = PostgreSQL::Test::Cluster->new('publisher');
 $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
 $node_publisher->init(allows_streaming => 'logical');
 $node_subscriber->init;
+$node_subscriber->append_conf('postgresql.conf', "max_logical_replication_workers = 10");
 $node_publisher->start;
 $node_subscriber->start;
 $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
diff --git a/src/test/subscription/t/050_parallel_apply.pl b/src/test/subscription/t/050_parallel_apply.pl
new file mode 100644
index 00000000000..69cf48cb7ac
--- /dev/null
+++ b/src/test/subscription/t/050_parallel_apply.pl
@@ -0,0 +1,130 @@
+
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+# This tests that dependency tracking between transactions can work well
+
+use strict;
+use warnings;
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+if ($ENV{enable_injection_points} ne 'yes')
+{
+	plan skip_all => 'Injection points not supported by this build';
+}
+
+# Initialize publisher node
+my $node_publisher = PostgreSQL::Test::Cluster->new('publisher');
+$node_publisher->init(allows_streaming => 'logical');
+$node_publisher->start;
+
+# Insert initial data
+$node_publisher->safe_psql('postgres',
+    "CREATE TABLE regress_tab (id int PRIMARY KEY, value text);");
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(1, 10), 'test');");
+
+# Create a publication
+$node_publisher->safe_psql('postgres',
+    "CREATE PUBLICATION regress_pub FOR ALL TABLES;");
+
+# Initialize subscriber node
+my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
+$node_subscriber->init;
+$node_subscriber->append_conf('postgresql.conf', "log_min_messages = debug1");
+$node_subscriber->append_conf('postgresql.conf',
+	"max_logical_replication_workers = 10");
+$node_subscriber->start;
+
+# Check if the extension injection_points is available, as it may be
+# possible that this script is run with installcheck, where the module
+# would not be installed by default.
+if (!$node_subscriber->check_extension('injection_points'))
+{
+	plan skip_all => 'Extension injection_points not installed';
+}
+
+$node_subscriber->safe_psql('postgres', 'CREATE EXTENSION injection_points;');
+
+# Create a subscription
+my $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
+
+$node_subscriber->safe_psql('postgres',
+    "CREATE TABLE regress_tab (id int PRIMARY KEY, value text);");
+$node_subscriber->safe_psql('postgres',
+    "CREATE SUBSCRIPTION regress_sub CONNECTION '$publisher_connstr' PUBLICATION regress_pub;");
+
+# Wait for initial table sync to finish
+$node_subscriber->wait_for_subscription_sync($node_publisher, 'regress_sub');
+
+# Insert tuples on publisher
+#
+# XXX This may not enough to launch a parallel apply worker, because
+# table_states_not_ready is not discarded yet.
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(11, 20), 'test');");
+$node_publisher->wait_for_catchup('regress_sub');
+
+# Insert tuples again
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(21, 30), 'test');");
+$node_publisher->wait_for_catchup('regress_sub');
+
+# Verify the parallel apply worker is launched
+my $result = $node_subscriber->safe_psql('postgres',
+    "SELECT count(1) FROM pg_stat_activity WHERE backend_type = 'logical replication parallel worker'");
+is($result, '1', "parallel apply worker is laucnhed by a non-streamed transaction");
+
+# Attach an injection_point. Parallel workers would wait before the commit
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+# Insert tuples on publisher
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(31, 40), 'test');");
+
+# Wait until the parallel worker enters the injection point.
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+my $offset = -s $node_subscriber->logfile;
+
+# Insert tuples on publisher again. This transaction is independent from the
+# previous one, but the parallel worker would wait till it finishes
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(41, 50), 'test');");
+
+# Verify the parallel worker waits for the transaction
+my $str = $node_subscriber->wait_for_log(qr/wait for depended xid ([1-9][0-9]+)/, $offset);
+my $xid = $str =~ /wait for depended xid ([1-9][0-9]+)/;
+
+# Update tuples which have not been applied yet on subscriber because the
+# parallel worker stops at the injection point. Newly assigned worker also
+# waits for the same transactions as above.
+$node_publisher->safe_psql('postgres',
+    "UPDATE regress_tab SET value = 'updated' WHERE id BETWEEN 31 AND 35;");
+
+# Verify the parallel worker waits for the same transaction
+$node_subscriber->wait_for_log(qr/wait for depended xid $xid/, $offset);
+
+# Wakeup the parallel worker. We detach first no to stop other parallel workers
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+# Verify the parallel worker wakes up
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab");
+is ($result, 50, 'inserts are replicated to subscriber');
+
+$result =
+  $node_subscriber->safe_psql('postgres',
+    "SELECT count(1) FROM regress_tab WHERE value = 'updated'");
+is ($result, 5, 'updates are also replicated to subscriber');
+
+done_testing();
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index ceb3fc5d980..80810793746 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2089,6 +2089,7 @@ ParallelHashGrowth
 ParallelHashJoinBatch
 ParallelHashJoinBatchAccessor
 ParallelHashJoinState
+ParallelizedTxnEntry
 ParallelIndexScanDesc
 ParallelSlot
 ParallelSlotArray
@@ -2573,6 +2574,8 @@ ReparameterizeForeignPathByChild_function
 ReplaceVarsFromTargetList_context
 ReplaceVarsNoMatchOption
 ReplaceWrapOption
+ReplicaIdentityEntry
+ReplicaIdentityKey
 ReplicaIdentityStmt
 ReplicationKind
 ReplicationSlot
@@ -4082,6 +4085,7 @@ rendezvousHashEntry
 rep
 replace_rte_variables_callback
 replace_rte_variables_context
+replica_identity_hash
 report_error_fn
 ret_type
 rewind_source
-- 
2.47.3



  [application/octet-stream] v7-0005-support-2PC.patch (13.9K, ../../TY7PR01MB145541DD6F5BE4CD403BA39FDF586A@TY7PR01MB14554.jpnprd01.prod.outlook.com/6-v7-0005-support-2PC.patch)
  download | inline diff:
From 99fe54601c3c28eef019776fd21bf765100446b6 Mon Sep 17 00:00:00 2001
From: Hayato Kuroda <[email protected]>
Date: Tue, 2 Dec 2025 13:01:26 +0900
Subject: [PATCH v7 5/8] support 2PC

This patch allows the PREPARE transaction to be applied in parallel. Parallel
apply workers are assigned to a transaction when BEGIN_PREPARE is received. This
part and the dependency-waiting mechanism are the same as a normal transaction.

A parallel worker can be freed after it handles a PREPARE message. The prepared
transaction can be deleted from parallelized_txns at that time; the upcoming
transactions will wait until then.

The leader apply worker resolves COMMIT PREPARED/ROLLBACK PREPARED. Since it can
be serialized automatically, it does not add the transaction to parallelized_txns.
---
 src/backend/replication/logical/worker.c      | 238 +++++++++++++++---
 src/test/subscription/t/050_parallel_apply.pl |  60 +++++
 2 files changed, 270 insertions(+), 28 deletions(-)

diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 3832481647e..99a0aeb1757 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -756,6 +756,14 @@ cleanup_replica_identity_table(List *committed_xid)
 	if (!committed_xid)
 		return;
 
+	/*
+	 * Skip if the replica_identity_table is not initialized yet. This can
+	 * happen if the empty transaction was replicated and a parallel apply
+	 * worker was launched. See comments in apply_handle_prepare().
+	 */
+	if (!replica_identity_table)
+		return;
+
 	replica_identity_start_iterate(replica_identity_table, &i);
 	while ((rientry = replica_identity_iterate(replica_identity_table, &i)) != NULL)
 	{
@@ -2116,6 +2124,11 @@ static void
 apply_handle_begin_prepare(StringInfo s)
 {
 	LogicalRepPreparedTxnData begin_data;
+	ParallelApplyWorkerInfo *winfo;
+	TransApplyAction apply_action;
+
+	/* Save the message before it is consumed. */
+	StringInfoData original_msg = *s;
 
 	/* Tablesync should never receive prepare. */
 	if (am_tablesync_worker())
@@ -2127,12 +2140,61 @@ apply_handle_begin_prepare(StringInfo s)
 	Assert(!TransactionIdIsValid(stream_xid));
 
 	logicalrep_read_begin_prepare(s, &begin_data);
-	set_apply_error_context_xact(begin_data.xid, begin_data.prepare_lsn);
+
+	remote_xid = begin_data.xid;
+
+	set_apply_error_context_xact(remote_xid, begin_data.prepare_lsn);
 
 	remote_final_lsn = begin_data.prepare_lsn;
 
 	maybe_start_skipping_changes(begin_data.prepare_lsn);
 
+	pa_allocate_worker(remote_xid, false);
+
+	apply_action = get_transaction_apply_action(remote_xid, &winfo);
+
+	elog(DEBUG1, "new remote_xid %u", remote_xid);
+	switch (apply_action)
+	{
+		case TRANS_LEADER_APPLY:
+			break;
+
+		case TRANS_LEADER_SEND_TO_PARALLEL:
+			Assert(winfo);
+
+			if (pa_send_data(winfo, s->len, s->data))
+			{
+				pa_set_stream_apply_worker(winfo);
+				break;
+			}
+
+			/*
+			 * Switch to serialize mode when we are not able to send the
+			 * change to parallel apply worker.
+			 */
+			pa_switch_to_partial_serialize(winfo, true);
+
+/* fall through */
+		case TRANS_LEADER_PARTIAL_SERIALIZE:
+			Assert(winfo);
+
+			stream_write_change(LOGICAL_REP_MSG_BEGIN_PREPARE, &original_msg);
+
+			/* Cache the parallel apply worker for this transaction. */
+			pa_set_stream_apply_worker(winfo);
+			break;
+
+		case TRANS_PARALLEL_APPLY:
+			/* Hold the lock until the end of the transaction. */
+			pa_lock_transaction(MyParallelShared->xid, AccessExclusiveLock);
+			pa_set_xact_state(MyParallelShared, PARALLEL_TRANS_STARTED);
+			break;
+
+		default:
+			elog(ERROR, "unexpected apply action: %d", (int) apply_action);
+			break;
+	}
+
 	in_remote_transaction = true;
 
 	pgstat_report_activity(STATE_RUNNING, NULL);
@@ -2182,6 +2244,11 @@ static void
 apply_handle_prepare(StringInfo s)
 {
 	LogicalRepPreparedTxnData prepare_data;
+	ParallelApplyWorkerInfo *winfo;
+	TransApplyAction apply_action;
+
+	/* Save the message before it is consumed. */
+	StringInfoData original_msg = *s;
 
 	logicalrep_read_prepare(s, &prepare_data);
 
@@ -2192,36 +2259,136 @@ apply_handle_prepare(StringInfo s)
 								 LSN_FORMAT_ARGS(prepare_data.prepare_lsn),
 								 LSN_FORMAT_ARGS(remote_final_lsn))));
 
-	/*
-	 * Unlike commit, here, we always prepare the transaction even though no
-	 * change has happened in this transaction or all changes are skipped. It
-	 * is done this way because at commit prepared time, we won't know whether
-	 * we have skipped preparing a transaction because of those reasons.
-	 *
-	 * XXX, We can optimize such that at commit prepared time, we first check
-	 * whether we have prepared the transaction or not but that doesn't seem
-	 * worthwhile because such cases shouldn't be common.
-	 */
-	begin_replication_step();
+	apply_action = get_transaction_apply_action(remote_xid, &winfo);
 
-	apply_handle_prepare_internal(&prepare_data);
+	switch (apply_action)
+	{
+		case TRANS_LEADER_APPLY:
+			/*
+			 * Unlike commit, here, we always prepare the transaction even
+			 * though no change has happened in this transaction or all changes
+			 * are skipped. It is done this way because at commit prepared
+			 * time, we won't know whether we have skipped preparing a
+			 * transaction because of those reasons.
+			 *
+			 * XXX, We can optimize such that at commit prepared time, we first
+			 * check whether we have prepared the transaction or not but that
+			 * doesn't seem worthwhile because such cases shouldn't be common.
+			 */
+			begin_replication_step();
 
-	end_replication_step();
-	CommitTransactionCommand();
-	pgstat_report_stat(false);
+			/* Wait until the last transaction finishes */
+			if (TransactionIdIsValid(last_remote_xid))
+				pa_wait_for_depended_transaction(last_remote_xid);
 
-	/*
-	 * It is okay not to set the local_end LSN for the prepare because we
-	 * always flush the prepare record. So, we can send the acknowledgment of
-	 * the remote_end LSN as soon as prepare is finished.
-	 *
-	 * XXX For the sake of consistency with commit, we could have set it with
-	 * the LSN of prepare but as of now we don't track that value similar to
-	 * XactLastCommitEnd, and adding it for this purpose doesn't seems worth
-	 * it.
-	 */
-	store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr,
-						 InvalidTransactionId);
+			apply_handle_prepare_internal(&prepare_data);
+
+			end_replication_step();
+			CommitTransactionCommand();
+			pgstat_report_stat(false);
+
+			/*
+			 * It is okay not to set the local_end LSN for the prepare because
+			 * we always flush the prepare record. So, we can send the
+			 * acknowledgment of the remote_end LSN as soon as prepare is
+			 * finished.
+			 *
+			 * XXX For the sake of consistency with commit, we could have set
+			 * it with the LSN of prepare but as of now we don't track that
+			 * value similar to XactLastCommitEnd, and adding it for this
+			 * purpose doesn't seems worth
+			 * it.
+			 */
+			store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr,
+								 InvalidTransactionId);
+
+			break;
+
+		case TRANS_LEADER_SEND_TO_PARALLEL:
+			Assert(winfo);
+
+			/*
+			 * Mark this transaction as parallelized. This ensures that
+			 * upcoming transactions wait until this transaction is committed.
+			 */
+			pa_add_parallelized_transaction(remote_xid);
+
+			/*
+			 * Build a dependency between this transaction and the lastly
+			 * committed transaction to preserve the commit order. Then try to
+			 * send a COMMIT message if succeeded.
+			 */
+			if (build_dependency_with_last_committed_txn(winfo) &&
+				pa_send_data(winfo, s->len, s->data))
+			{
+				/* Finish processing the transaction. */
+				pa_xact_finish(winfo, prepare_data.end_lsn);
+				break;
+			}
+
+			/*
+			 * Switch to serialize mode when we are not able to send the
+			 * change to parallel apply worker.
+			 */
+			pa_switch_to_partial_serialize(winfo, true);
+/* fall through */
+		case TRANS_LEADER_PARTIAL_SERIALIZE:
+			Assert(winfo);
+
+			stream_open_and_write_change(remote_xid, LOGICAL_REP_MSG_PREPARE,
+										 &original_msg);
+
+			pa_set_fileset_state(winfo->shared, FS_SERIALIZE_DONE);
+
+			/* Finish processing the transaction. */
+			pa_xact_finish(winfo, prepare_data.end_lsn);
+			break;
+
+		case TRANS_PARALLEL_APPLY:
+
+			/*
+			 * If the parallel apply worker is applying spooled messages then
+			 * close the file before committing.
+			 */
+			if (stream_fd)
+				stream_close_file();
+
+			begin_replication_step();
+
+			INJECTION_POINT("parallel-worker-before-prepare", NULL);
+
+			/* Mark the transaction as prepared. */
+			apply_handle_prepare_internal(&prepare_data);
+
+			end_replication_step();
+
+			CommitTransactionCommand();
+			pgstat_report_stat(false);
+
+			store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr,
+								 InvalidTransactionId);
+
+			/*
+			 * It is okay not to set the local_end LSN for the prepare because
+			 * we always flush the prepare record. See apply_handle_prepare.
+			 */
+			MyParallelShared->last_commit_end = InvalidXLogRecPtr;
+			pa_commit_transaction();
+
+			pa_unlock_transaction(MyParallelShared->xid, AccessExclusiveLock);
+
+			pa_reset_subtrans();
+			break;
+
+		default:
+			elog(ERROR, "unexpected apply action: %d", (int) apply_action);
+			break;
+	}
+
+	/* Cache the remote_xid */
+	last_remote_xid = remote_xid;
+
+	remote_xid = InvalidTransactionId;
 
 	in_remote_transaction = false;
 
@@ -2269,6 +2436,9 @@ apply_handle_commit_prepared(StringInfo s)
 	/* There is no transaction when COMMIT PREPARED is called */
 	begin_replication_step();
 
+	if (TransactionIdIsValid(last_remote_xid))
+		pa_wait_for_depended_transaction(last_remote_xid);
+
 	/*
 	 * Update origin state so we can restart streaming from correct position
 	 * in case of crash.
@@ -2281,6 +2451,14 @@ apply_handle_commit_prepared(StringInfo s)
 	CommitTransactionCommand();
 	pgstat_report_stat(false);
 
+	/*
+	 * No need to update last_remote_xid because leader worker applied the
+	 * message thus upcoming transaction preserves the order automatically.
+	 * Let's set the xid to an invalid value to skip sending the
+	 * INTERNAL_DEPENDENCY message.
+	 */
+	last_remote_xid = InvalidTransactionId;
+
 	store_flush_position(prepare_data.end_lsn, XactLastCommitEnd,
 						 InvalidTransactionId);
 	in_remote_transaction = false;
@@ -2337,6 +2515,10 @@ apply_handle_rollback_prepared(StringInfo s)
 
 		/* There is no transaction when ABORT/ROLLBACK PREPARED is called */
 		begin_replication_step();
+
+		if (TransactionIdIsValid(last_remote_xid))
+			pa_wait_for_depended_transaction(last_remote_xid);
+
 		FinishPreparedTransaction(gid, false);
 		end_replication_step();
 		CommitTransactionCommand();
diff --git a/src/test/subscription/t/050_parallel_apply.pl b/src/test/subscription/t/050_parallel_apply.pl
index 69cf48cb7ac..15973f7d0e0 100644
--- a/src/test/subscription/t/050_parallel_apply.pl
+++ b/src/test/subscription/t/050_parallel_apply.pl
@@ -17,6 +17,8 @@ if ($ENV{enable_injection_points} ne 'yes')
 # Initialize publisher node
 my $node_publisher = PostgreSQL::Test::Cluster->new('publisher');
 $node_publisher->init(allows_streaming => 'logical');
+$node_publisher->append_conf('postgresql.conf',
+    "max_prepared_transactions = 10");
 $node_publisher->start;
 
 # Insert initial data
@@ -35,6 +37,8 @@ $node_subscriber->init;
 $node_subscriber->append_conf('postgresql.conf', "log_min_messages = debug1");
 $node_subscriber->append_conf('postgresql.conf',
 	"max_logical_replication_workers = 10");
+$node_subscriber->append_conf('postgresql.conf',
+    "max_prepared_transactions = 10");
 $node_subscriber->start;
 
 # Check if the extension injection_points is available, as it may be
@@ -127,4 +131,60 @@ $result =
     "SELECT count(1) FROM regress_tab WHERE value = 'updated'");
 is ($result, 5, 'updates are also replicated to subscriber');
 
+# Ensure PREPAREd transaction also affects the parallel apply
+
+$node_subscriber->safe_psql('postgres',
+    "ALTER SUBSCRIPTION regress_sub DISABLE;");
+$node_subscriber->poll_query_until('postgres',
+	"SELECT count(*) = 0 FROM pg_stat_activity WHERE backend_type = 'logical replication apply worker'"
+);
+$node_subscriber->safe_psql(
+	'postgres', "
+    ALTER SUBSCRIPTION regress_sub SET (two_phase = on);
+    ALTER SUBSCRIPTION regress_sub ENABLE;");
+
+$result = $node_subscriber->safe_psql('postgres',
+    "SELECT count(1) FROM pg_stat_activity WHERE backend_type = 'logical replication parallel worker'");
+is($result, '0', "no parallel apply workers exist after restart");
+
+# Attach an injection_point. Parallel workers would wait before the prepare
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-prepare','wait');"
+);
+
+# PREPARE a transaction on publisher. It would be handled by a parallel apply
+# worker.
+$node_publisher->safe_psql('postgres', qq[
+    BEGIN;
+    INSERT INTO regress_tab VALUES (generate_series(51, 60), 'prepare');
+    PREPARE TRANSACTION 'regress_prepare';
+]);
+
+# Wait until the parallel worker enters the injection point.
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-prepare');
+
+$offset = -s $node_subscriber->logfile;
+
+# Insert tuples on publisher again. This transaction waits for the prepared
+# transaction
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(61, 70), 'test');");
+
+# Verify the parallel worker waits for the transaction
+$str = $node_subscriber->wait_for_log(qr/wait for depended xid ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /wait for depended xid ([1-9][0-9]+)/;
+
+# Wakeup the parallel worker
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-prepare');
+    SELECT injection_points_wakeup('parallel-worker-before-prepare');
+]);
+
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+
+# COMMIT the prepared transaction. It is always handled by the leader
+$node_publisher->safe_psql('postgres', "COMMIT PREPARED 'regress_prepare';");
+$node_publisher->wait_for_catchup('regress_sub');
+
 done_testing();
-- 
2.47.3



  [application/octet-stream] v7-0006-Track-dependencies-for-streamed-transactions.patch (10.7K, ../../TY7PR01MB145541DD6F5BE4CD403BA39FDF586A@TY7PR01MB14554.jpnprd01.prod.outlook.com/7-v7-0006-Track-dependencies-for-streamed-transactions.patch)
  download | inline diff:
From db1f78b4fe74e7419f2c7f0a4959207376494c9f Mon Sep 17 00:00:00 2001
From: Hayato Kuroda <[email protected]>
Date: Thu, 4 Dec 2025 20:55:26 +0900
Subject: [PATCH v7 6/8] Track dependencies for streamed transactions

This commit allows tracking dependencies of streamed transactions.

Regarding the streaming=on case, dependency tracking is enabled while applying
spooled changes from files.

In the streaming=parallel case, dependency tracking is performed when the leader
sends changes to parallel workers. Apart from non-streamed transactions, the
leader waits for parallel workers till the assigned transactions are finished at
COMMIT/PREPARE/ABORT; thus, the XID of streamed transactions is not cached as
the lastly handled one. Also, streamed transactions are not recorded as
parallelized transactions because upcoming workers do not have to wait for them.
---
 .../replication/logical/applyparallelworker.c | 19 +++++-
 src/backend/replication/logical/worker.c      | 66 +++++++++++++++++--
 src/include/replication/worker_internal.h     |  2 +-
 src/test/subscription/t/050_parallel_apply.pl | 47 +++++++++++++
 4 files changed, 126 insertions(+), 8 deletions(-)

diff --git a/src/backend/replication/logical/applyparallelworker.c b/src/backend/replication/logical/applyparallelworker.c
index 5b6267c6047..bb66d64582c 100644
--- a/src/backend/replication/logical/applyparallelworker.c
+++ b/src/backend/replication/logical/applyparallelworker.c
@@ -168,7 +168,14 @@
  * key) as another ongoing transaction (see handle_dependency_on_change for
  * details). If so, the leader sends a list of dependent transaction IDs to the
  * parallel worker, indicating that the parallel apply worker must wait for
- * these transactions to commit before proceeding.
+ * these transactions to commit before proceeding. If transactions are streamed
+ * but leader deciedes no to assign parallel apply workers, dependencies are
+ * verified when the transaction is committed.
+ *
+ * Non-streaming transactions
+ * ======================
+ * The handling is similar to streaming transactions, but including few
+ * differences:
  *
  * Commit order
  * ------------
@@ -1635,6 +1642,12 @@ pa_set_stream_apply_worker(ParallelApplyWorkerInfo *winfo)
 	stream_apply_worker = winfo;
 }
 
+bool
+pa_stream_apply_worker_is_null(void)
+{
+	return stream_apply_worker == NULL;
+}
+
 /*
  * Form a unique savepoint name for the streaming transaction.
  *
@@ -1720,6 +1733,10 @@ pa_stream_abort(LogicalRepStreamAbortData *abort_data)
 	TransactionId xid = abort_data->xid;
 	TransactionId subxid = abort_data->subxid;
 
+	/* Streamed transactions won't be registered */
+	Assert(!dshash_find(parallelized_txns, &xid, false) &&
+		   !dshash_find(parallelized_txns, &subxid, false));
+
 	/*
 	 * Update origin state so we can restart streaming from correct position
 	 * in case of crash.
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 99a0aeb1757..83bcf216fd3 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -969,13 +969,26 @@ check_dependency_on_replica_identity(Oid relid,
 													   &rientry->remote_xid,
 													   new_depended_xid);
 
+	/*
+	 * Remove the entry if it is registered for the streamed transactions. We
+	 * do not have to register an entry for them; The leader worker always
+	 * waits until the parallel worker finishes handling streamed transactions,
+	 * thus no need to consider the possiblity that upcoming parallel workers
+	 * would go ahead.
+	 */
+	if (TransactionIdIsValid(stream_xid) && !found)
+	{
+		free_replica_identity_key(rientry->keydata);
+		replica_identity_delete_item(replica_identity_table, rientry);
+	}
+
 	/*
 	 * Update the new depended xid into the entry if valid, the new xid could
 	 * be invalid if the transaction will be applied by the leader itself
 	 * which means all the changes will be committed before processing next
 	 * transaction, so no need to be depended on.
 	 */
-	if (TransactionIdIsValid(new_depended_xid))
+	else if (TransactionIdIsValid(new_depended_xid))
 		rientry->remote_xid = new_depended_xid;
 
 	/*
@@ -1089,8 +1102,11 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 	 */
 	StringInfoData change = *s;
 
-	/* Compute dependency only for non-streaming transaction */
-	if (in_streamed_transaction || (winfo && winfo->stream_txn))
+	/*
+	 * Skip if we are handling streaming transactions but changes are not
+	 * applied yet.
+	 */
+	if (pa_stream_apply_worker_is_null() && in_streamed_transaction)
 		return;
 
 	/* Only the leader checks dependencies and schedules the parallel apply */
@@ -1450,7 +1466,18 @@ handle_streamed_transaction(LogicalRepMsgType action, StringInfo s)
 				(errcode(ERRCODE_PROTOCOL_VIOLATION),
 				 errmsg_internal("invalid transaction ID in streamed replication transaction")));
 
-	handle_dependency_on_change(action, s, current_xid, winfo);
+	/*
+	 * Check dependencies related to the received change. The XID of the top
+	 * transaction is always used to avoid detecting false-positive
+	 * dependencies between top and sub transactions. Sub-transactions can be
+	 * replicated for streamed transactions, and they won't be marked as
+	 * parallelized so that parallel workers won't wait for rolled-back
+	 * sub-transactions.
+	 */
+	handle_dependency_on_change(action, s,
+								in_streamed_transaction
+								? stream_xid : remote_xid,
+								winfo);
 
 	/*
 	 * Re-fetch the latest apply action as it might have been changed during
@@ -2587,6 +2614,10 @@ apply_handle_stream_prepare(StringInfo s)
 			apply_spooled_messages(MyLogicalRepWorker->stream_fileset,
 								   prepare_data.xid, prepare_data.prepare_lsn);
 
+			/* Wait until the last transaction finishes */
+			if (TransactionIdIsValid(last_remote_xid))
+				pa_wait_for_depended_transaction(last_remote_xid);
+
 			/* Mark the transaction as prepared. */
 			apply_handle_prepare_internal(&prepare_data);
 
@@ -2610,7 +2641,8 @@ apply_handle_stream_prepare(StringInfo s)
 		case TRANS_LEADER_SEND_TO_PARALLEL:
 			Assert(winfo);
 
-			if (pa_send_data(winfo, s->len, s->data))
+			if (build_dependency_with_last_committed_txn(winfo) &&
+				pa_send_data(winfo, s->len, s->data))
 			{
 				/* Finish processing the streaming transaction. */
 				pa_xact_finish(winfo, prepare_data.end_lsn);
@@ -2676,6 +2708,11 @@ apply_handle_stream_prepare(StringInfo s)
 
 	pgstat_report_stat(false);
 
+	/*
+	 * No need to update the last_remote_xid here because leader worker
+	 * always wait until streamed transactions finish.
+	 */
+
 	/*
 	 * Process any tables that are being synchronized in parallel, as well as
 	 * any newly added tables or sequences.
@@ -3460,6 +3497,10 @@ apply_handle_stream_commit(StringInfo s)
 			apply_spooled_messages(MyLogicalRepWorker->stream_fileset, xid,
 								   commit_data.commit_lsn);
 
+			/* Wait until the last transaction finishes */
+			if (TransactionIdIsValid(last_remote_xid))
+				pa_wait_for_depended_transaction(last_remote_xid);
+
 			apply_handle_commit_internal(&commit_data);
 
 			/* Unlink the files with serialized changes and subxact info. */
@@ -3471,7 +3512,20 @@ apply_handle_stream_commit(StringInfo s)
 		case TRANS_LEADER_SEND_TO_PARALLEL:
 			Assert(winfo);
 
-			if (pa_send_data(winfo, s->len, s->data))
+			/*
+			 * Apart from non-streaming case, no need to mark this transaction
+			 * as parallelized. Because the leader waits until the streamed
+			 * transaction is committed thus commit ordering is always
+			 * preserved.
+			 */
+
+			/*
+			 * Build a dependency between this transaction and the lastly
+			 * committed transaction to preserve the commit order. Then try to
+			 * send a COMMIT message if succeeded.
+			 */
+			if (build_dependency_with_last_committed_txn(winfo) &&
+				pa_send_data(winfo, s->len, s->data))
 			{
 				/* Finish processing the streaming transaction. */
 				pa_xact_finish(winfo, commit_data.end_lsn);
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index 5371ee767f1..69ecd51a359 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -354,7 +354,7 @@ extern void pa_switch_to_partial_serialize(ParallelApplyWorkerInfo *winfo,
 extern void pa_set_xact_state(ParallelApplyWorkerShared *wshared,
 							  ParallelTransState xact_state);
 extern void pa_set_stream_apply_worker(ParallelApplyWorkerInfo *winfo);
-
+extern bool pa_stream_apply_worker_is_null(void);
 extern void pa_start_subtrans(TransactionId current_xid,
 							  TransactionId top_xid);
 extern void pa_reset_subtrans(void);
diff --git a/src/test/subscription/t/050_parallel_apply.pl b/src/test/subscription/t/050_parallel_apply.pl
index 15973f7d0e0..9254b85d350 100644
--- a/src/test/subscription/t/050_parallel_apply.pl
+++ b/src/test/subscription/t/050_parallel_apply.pl
@@ -187,4 +187,51 @@ $node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset
 $node_publisher->safe_psql('postgres', "COMMIT PREPARED 'regress_prepare';");
 $node_publisher->wait_for_catchup('regress_sub');
 
+# Ensure streamed transactions waits the previous transaction
+
+$node_publisher->append_conf('postgresql.conf',
+   "logical_decoding_work_mem = 64kB");
+$node_publisher->reload;
+# Run a query to make sure that the reload has taken effect.
+$node_publisher->safe_psql('postgres', "SELECT 1");
+
+# Attach the injection_point again
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(71, 80), 'test');");
+
+# Wait until the parallel worker enters the injection point.
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+# Run a transaction which would be streamed
+my $h = $node_publisher->background_psql('postgres', on_error_stop => 0);
+
+$offset = -s $node_subscriber->logfile;
+
+$h->query_safe(
+	q{
+BEGIN;
+UPDATE regress_tab SET value = 'streamed-updated' WHERE id BETWEEN 71 AND 80;
+INSERT INTO regress_tab VALUES (generate_series(100, 5100), 'streamed');
+});
+
+# Verify the parallel worker waits for the transaction
+$str = $node_subscriber->wait_for_log(qr/wait for depended xid ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /wait for depended xid ([1-9][0-9]+)/;
+
+# Wakeup the parallel worker
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+# Verify the streamed transaction can be applied
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+
+$h->query_safe("COMMIT;");
+
 done_testing();
-- 
2.47.3



  [application/octet-stream] v7-0007-Wait-applying-transaction-if-one-of-user-defined-.patch (11.8K, ../../TY7PR01MB145541DD6F5BE4CD403BA39FDF586A@TY7PR01MB14554.jpnprd01.prod.outlook.com/8-v7-0007-Wait-applying-transaction-if-one-of-user-defined-.patch)
  download | inline diff:
From 45c2c40caa13219e7860cb81d86fa9d742cc1798 Mon Sep 17 00:00:00 2001
From: Hayato Kuroda <[email protected]>
Date: Tue, 23 Dec 2025 17:58:15 +0900
Subject: [PATCH v7 7/8] Wait applying transaction if one of user-defined
 triggers is immutable

Since many parallel workers apply transactions, triggers for relations can also
be fired in parallel, which may obtain unexpected results. To make it safe,
parallel apply workers wait for the previously dispatched transaction before
applying changes to the relation that has mutable triggers.
---
 src/backend/replication/logical/relation.c | 123 ++++++++++++++++++---
 src/backend/replication/logical/worker.c   |  68 ++++++++++++
 src/include/replication/logicalrelation.h  |  20 ++++
 3 files changed, 197 insertions(+), 14 deletions(-)

diff --git a/src/backend/replication/logical/relation.c b/src/backend/replication/logical/relation.c
index 9991bfe76cc..14f3ebf725e 100644
--- a/src/backend/replication/logical/relation.c
+++ b/src/backend/replication/logical/relation.c
@@ -21,7 +21,9 @@
 #include "access/genam.h"
 #include "access/table.h"
 #include "catalog/namespace.h"
+#include "catalog/pg_proc.h"
 #include "catalog/pg_subscription_rel.h"
+#include "commands/trigger.h"
 #include "executor/executor.h"
 #include "nodes/makefuncs.h"
 #include "replication/logicalrelation.h"
@@ -159,6 +161,10 @@ logicalrep_relmap_free_entry(LogicalRepRelMapEntry *entry)
  *
  * Called when new relation mapping is sent by the publisher to update
  * our expected view of incoming data from said publisher.
+ *
+ * Note that we do not check the user-defined constraints here. PostgreSQL has
+ * already assumed that CHECK constraints' conditions are immutable and here
+ * follows the rule.
  */
 void
 logicalrep_relmap_update(LogicalRepRelation *remoterel)
@@ -208,6 +214,8 @@ logicalrep_relmap_update(LogicalRepRelation *remoterel)
 		(remoterel->relkind == 0) ? RELKIND_RELATION : remoterel->relkind;
 
 	entry->remoterel.attkeys = bms_copy(remoterel->attkeys);
+
+	entry->parallel_safe = LOGICALREP_PARALLEL_UNKNOWN;
 	MemoryContextSwitchTo(oldctx);
 }
 
@@ -353,27 +361,79 @@ logicalrep_rel_mark_updatable(LogicalRepRelMapEntry *entry)
 }
 
 /*
- * Open the local relation associated with the remote one.
+ * Check all local triggers for the relation to see the parallelizability.
  *
- * Rebuilds the Relcache mapping if it was invalidated by local DDL.
+ * We regard relations as applicable in parallel if all triggers are immutable.
+ * Result is directly set to LogicalRepRelMapEntry::parallel_safe.
  */
-LogicalRepRelMapEntry *
-logicalrep_rel_open(LogicalRepRelId remoteid, LOCKMODE lockmode)
+static void
+check_defined_triggers(LogicalRepRelMapEntry *entry)
+{
+	TriggerDesc *trigdesc = entry->localrel->trigdesc;
+
+	/* Quick exit if triffer is not defined */
+	if (trigdesc == NULL)
+	{
+		entry->parallel_safe = LOGICALREP_PARALLEL_SAFE;
+		return;
+	}
+
+	/* Seek triggers one by one to see the volatility */
+	for (int i = 0; i < trigdesc->numtriggers; i++)
+	{
+		Trigger *trigger = &trigdesc->triggers[i];
+
+		Assert(OidIsValid(trigger->tgfoid));
+
+		/* Skip if the trigger is not enabled for logical replication */
+		if (trigger->tgenabled == TRIGGER_DISABLED ||
+			trigger->tgenabled == TRIGGER_FIRES_ON_ORIGIN)
+			continue;
+
+		/* Check the volatility of the trigger. Exit if it is not immutable */
+		if (func_volatile(trigger->tgfoid) != PROVOLATILE_IMMUTABLE)
+		{
+			entry->parallel_safe = LOGICALREP_PARALLEL_RESTRICTED;
+			return;
+		}
+	}
+
+	/* All triggers are immutable, set as parallel safe */
+	entry->parallel_safe = LOGICALREP_PARALLEL_SAFE;
+}
+
+/*
+ * Actual workhorse for logicalrep_rel_open().
+ *
+ * Caller must specify *either* entry or key. If the entry is specified, its
+ * attributes are filled and returned. The logical relation is kept opening.
+ * If the key is given, the corresponding entry is first searched in the hash
+ * table and processed as in the above case. At the end, logical replication is
+ * closed.
+  */
+void
+logicalrep_rel_load(LogicalRepRelMapEntry *entry, LogicalRepRelId remoteid,
+					LOCKMODE lockmode)
 {
-	LogicalRepRelMapEntry *entry;
-	bool		found;
 	LogicalRepRelation *remoterel;
 
-	if (LogicalRepRelMap == NULL)
-		logicalrep_relmap_init();
+	Assert((entry && !remoteid) || (!entry && remoteid));
 
-	/* Search for existing entry. */
-	entry = hash_search(LogicalRepRelMap, &remoteid,
-						HASH_FIND, &found);
+	if (!entry)
+	{
+		bool	found;
 
-	if (!found)
-		elog(ERROR, "no relation map entry for remote relation ID %u",
-			 remoteid);
+		if (LogicalRepRelMap == NULL)
+			logicalrep_relmap_init();
+
+		/* Search for existing entry. */
+		entry = hash_search(LogicalRepRelMap, &remoteid,
+							HASH_FIND, &found);
+
+		if (!found)
+			elog(ERROR, "no relation map entry for remote relation ID %u",
+				remoteid);
+	}
 
 	remoterel = &entry->remoterel;
 
@@ -499,6 +559,13 @@ logicalrep_rel_open(LogicalRepRelId remoteid, LOCKMODE lockmode)
 		entry->localindexoid = FindLogicalRepLocalIndex(entry->localrel, remoterel,
 														entry->attrmap);
 
+		/*
+		 * Leader must also collect all local unique indexes for dependency
+		 * tracking.
+		 */
+		if (am_leader_apply_worker())
+			check_defined_triggers(entry);
+
 		entry->localrelvalid = true;
 	}
 
@@ -507,6 +574,34 @@ logicalrep_rel_open(LogicalRepRelId remoteid, LOCKMODE lockmode)
 											   entry->localreloid,
 											   &entry->statelsn);
 
+	if (remoteid)
+		logicalrep_rel_close(entry, lockmode);
+}
+
+/*
+ * Open the local relation associated with the remote one.
+ *
+ * Rebuilds the Relcache mapping if it was invalidated by local DDL.
+ */
+LogicalRepRelMapEntry *
+logicalrep_rel_open(LogicalRepRelId remoteid, LOCKMODE lockmode)
+{
+	LogicalRepRelMapEntry *entry;
+	bool		found;
+
+	if (LogicalRepRelMap == NULL)
+		logicalrep_relmap_init();
+
+	/* Search for existing entry. */
+	entry = hash_search(LogicalRepRelMap, &remoteid,
+						HASH_FIND, &found);
+
+	if (!found)
+		elog(ERROR, "no relation map entry for remote relation ID %u",
+			 remoteid);
+
+	logicalrep_rel_load(entry, 0, lockmode);
+
 	return entry;
 }
 
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 83bcf216fd3..c1cf301c97b 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -1070,6 +1070,59 @@ check_dependency_on_rel(LogicalRepRelId relid, TransactionId new_depended_xid,
 		relentry->last_depended_xid = new_depended_xid;
 }
 
+/*
+ * Check the parallelizability of applying changes for the relation.
+ * Append the lastly dispatched transaction in in 'depends_on_xids' if the
+ * relation is parallel unsafe.
+ */
+static void
+check_dependency_for_parallel_safety(LogicalRepRelId relid,
+									 TransactionId new_depended_xid,
+									 List **depends_on_xids)
+{
+	LogicalRepRelMapEntry *relentry;
+
+	/* Quick exit if no transactions have been dispatched */
+	if (!TransactionIdIsValid(last_remote_xid))
+		return;
+
+	relentry = logicalrep_get_relentry(relid);
+
+	/*
+	 * Gather information for local triggres if not yet. We require to be in a
+	 * transaction state because system catalogs are read.
+	 */
+	if (relentry->parallel_safe == LOGICALREP_PARALLEL_UNKNOWN)
+	{
+		bool	needs_start = !IsTransactionOrTransactionBlock();
+
+		if (needs_start)
+			StartTransactionCommand();
+
+		logicalrep_rel_load(NULL, relid, AccessShareLock);
+
+		/*
+		 * Close the transaction if we start here. We must not abort because it
+		 * would release all session-level locks, such as the stream lock, and
+		 * break the deadlock detection mechanism between LA and PA. The
+		 * outcome is the same regardless of the end status, since the
+		 * transaction did not modify any tuples.
+		 */
+		if (needs_start)
+			CommitTransactionCommand();
+
+		Assert(relentry->parallel_safe != LOGICALREP_PARALLEL_UNKNOWN);
+	}
+
+	/* Do nothing for parallel safe relations */
+	if (relentry->parallel_safe == LOGICALREP_PARALLEL_SAFE)
+		return;
+
+	*depends_on_xids = check_and_append_xid_dependency(*depends_on_xids,
+													   &last_remote_xid,
+													   new_depended_xid);
+}
+
 /*
  * Check dependencies related to the current change by determining if the
  * modification impacts the same row or table as another ongoing transaction. If
@@ -1128,6 +1181,8 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			check_dependency_on_replica_identity(relid, &newtup,
 												 new_depended_xid,
 												 &depends_on_xids);
+			check_dependency_for_parallel_safety(relid, new_depended_xid,
+												 &depends_on_xids);
 			break;
 
 		case LOGICAL_REP_MSG_UPDATE:
@@ -1135,13 +1190,19 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 										   &newtup);
 
 			if (has_oldtup)
+			{
 				check_dependency_on_replica_identity(relid, &oldtup,
 													 new_depended_xid,
 													 &depends_on_xids);
+				check_dependency_for_parallel_safety(relid, new_depended_xid,
+													 &depends_on_xids);
+			}
 
 			check_dependency_on_replica_identity(relid, &newtup,
 												 new_depended_xid,
 												 &depends_on_xids);
+			check_dependency_for_parallel_safety(relid, new_depended_xid,
+												 &depends_on_xids);
 			break;
 
 		case LOGICAL_REP_MSG_DELETE:
@@ -1149,6 +1210,8 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			check_dependency_on_replica_identity(relid, &oldtup,
 												 new_depended_xid,
 												 &depends_on_xids);
+			check_dependency_for_parallel_safety(relid, new_depended_xid,
+												 &depends_on_xids);
 			break;
 
 		case LOGICAL_REP_MSG_TRUNCATE:
@@ -1161,8 +1224,13 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			 * modified the same table.
 			 */
 			foreach_int(truncated_relid, remote_relids)
+			{
 				check_dependency_on_rel(truncated_relid, new_depended_xid,
 										&depends_on_xids);
+				check_dependency_for_parallel_safety(truncated_relid,
+													 new_depended_xid,
+													 &depends_on_xids);
+			}
 
 			break;
 
diff --git a/src/include/replication/logicalrelation.h b/src/include/replication/logicalrelation.h
index 34a7069e9e5..e3d0df58620 100644
--- a/src/include/replication/logicalrelation.h
+++ b/src/include/replication/logicalrelation.h
@@ -39,6 +39,20 @@ typedef struct LogicalRepRelMapEntry
 	XLogRecPtr	statelsn;
 
 	TransactionId last_depended_xid;
+
+	/*
+	 * Whether the relation can be applied in parallel or not. It is
+	 * distinglish whether defined triggers are the immutable or not.
+	 *
+	 * Theoretically, we can determine the parallelizability for each type of
+	 * replication messages, INSERT/UPDATE/DELETE/TRUNCATE. But it is not done
+	 * yet to reduce the number of attributes.
+	 *
+	 * Note that we do not check the user-defined constraints here. PostgreSQL
+	 * has already assumed that CHECK constraints' conditions are immutable and
+	 * here follows the rule.
+	 */
+	char		parallel_safe;
 } LogicalRepRelMapEntry;
 
 extern void logicalrep_relmap_update(LogicalRepRelation *remoterel);
@@ -46,6 +60,8 @@ extern void logicalrep_partmap_reset_relmap(LogicalRepRelation *remoterel);
 
 extern LogicalRepRelMapEntry *logicalrep_rel_open(LogicalRepRelId remoteid,
 												  LOCKMODE lockmode);
+extern void logicalrep_rel_load(LogicalRepRelMapEntry *entry,
+								LogicalRepRelId remoteid, LOCKMODE lockmode);
 extern LogicalRepRelMapEntry *logicalrep_partition_open(LogicalRepRelMapEntry *root,
 														Relation partrel, AttrMap *map);
 extern void logicalrep_rel_close(LogicalRepRelMapEntry *rel,
@@ -56,4 +72,8 @@ extern int	logicalrep_get_num_rels(void);
 extern void logicalrep_write_all_rels(StringInfo out);
 extern LogicalRepRelMapEntry *logicalrep_get_relentry(LogicalRepRelId remoteid);
 
+#define LOGICALREP_PARALLEL_SAFE		's'
+#define LOGICALREP_PARALLEL_RESTRICTED	'r'
+#define LOGICALREP_PARALLEL_UNKNOWN		'u'
+
 #endif							/* LOGICALRELATION_H */
-- 
2.47.3



  [application/octet-stream] v7-0008-Support-dependency-tracking-via-local-unique-inde.patch (23.3K, ../../TY7PR01MB145541DD6F5BE4CD403BA39FDF586A@TY7PR01MB14554.jpnprd01.prod.outlook.com/9-v7-0008-Support-dependency-tracking-via-local-unique-inde.patch)
  download | inline diff:
From 0ff666fdd43e02abdcc8a151410c18fb52e9e293 Mon Sep 17 00:00:00 2001
From: Hayato Kuroda <[email protected]>
Date: Thu, 11 Dec 2025 22:21:47 +0900
Subject: [PATCH v7 8/8] Support dependency tracking via local unique indexes

Currently, logical replication's parallel apply mechanism tracks dependencies
primarily based on the REPLICA IDENTITY defined on the publisher table.
However, local subscriber tables might have additional unique indexes that
could effectively serve as dependency keys, even if they don't correspond to
the publisher's REPLICA IDENTITY. Failing to track these additional unique
keys can lead to incorrect data and/or deadlocks during parallel application.

This patch extends the parallel apply's dependency tracking to consider
local unique indexes on the subscriber table. This is achieved by extending
the existing Replica Identity hash table to also store dependency information
based on these local unique indexes.

The LogicalRepRelMapEntry structure is extended to store details about these
local unique indexes. This information is collected and cached when
dependency checking is first performed for a remote transaction on a given
relation. This collection process requires to be in a transaction to access
system catalog information.
---
 src/backend/replication/logical/relation.c    | 161 ++++++++++-
 src/backend/replication/logical/worker.c      | 270 ++++++++++++++----
 src/backend/storage/lmgr/deadlock.c           |   1 -
 src/include/replication/logicalrelation.h     |  14 +
 src/test/subscription/t/050_parallel_apply.pl |  43 +++
 src/tools/pgindent/typedefs.list              |   2 +
 6 files changed, 432 insertions(+), 59 deletions(-)

diff --git a/src/backend/replication/logical/relation.c b/src/backend/replication/logical/relation.c
index 14f3ebf725e..781fc3c73b9 100644
--- a/src/backend/replication/logical/relation.c
+++ b/src/backend/replication/logical/relation.c
@@ -127,6 +127,21 @@ logicalrep_relmap_init(void)
 								  (Datum) 0);
 }
 
+/*
+ * Release local index list
+ */
+static void
+free_local_unique_indexes(LogicalRepRelMapEntry *entry)
+{
+	Assert(am_leader_apply_worker());
+
+	foreach_ptr(LogicalRepSubscriberIdx, idxinfo, entry->local_unique_indexes)
+		bms_free(idxinfo->indexkeys);
+
+	list_free_deep(entry->local_unique_indexes);
+	entry->local_unique_indexes = NIL;
+}
+
 /*
  * Free the entry of a relation map cache.
  */
@@ -154,6 +169,9 @@ logicalrep_relmap_free_entry(LogicalRepRelMapEntry *entry)
 
 	if (entry->attrmap)
 		free_attrmap(entry->attrmap);
+
+	if (entry->local_unique_indexes != NIL)
+		free_local_unique_indexes(entry);
 }
 
 /*
@@ -360,6 +378,126 @@ logicalrep_rel_mark_updatable(LogicalRepRelMapEntry *entry)
 	}
 }
 
+/*
+ * Collect all local unique indexes that can be used for dependency tracking.
+ */
+static void
+collect_local_indexes(LogicalRepRelMapEntry *entry)
+{
+	List	   *idxlist;
+
+	if (entry->local_unique_indexes != NIL)
+		free_local_unique_indexes(entry);
+
+	entry->local_unique_indexes_collected = true;
+
+	idxlist = RelationGetIndexList(entry->localrel);
+
+	/* Quick exit if there are no indexes */
+	if (idxlist == NIL)
+		return;
+
+	/* Iterate indexes to list all usable indexes */
+	foreach_oid(idxoid, idxlist)
+	{
+		Relation	idxrel;
+		int			indnkeys;
+		AttrMap	   *attrmap;
+		Bitmapset  *indexkeys = NULL;
+		bool		suitable = true;
+
+		idxrel = index_open(idxoid, AccessShareLock);
+
+		/*
+		 * Check whether the index can be used for the dependency tracking.
+		 *
+		 * For simplification, the same condition as REPLICA IDENTITY FULL,
+		 * plus it must be a unique index.
+		 */
+		if (!(idxrel->rd_index->indisunique &&
+			  IsIndexUsableForReplicaIdentityFull(idxrel, entry->attrmap)))
+		{
+			index_close(idxrel, AccessShareLock);
+			continue;
+		}
+
+		indnkeys = idxrel->rd_index->indnkeyatts;
+		attrmap = entry->attrmap;
+
+		Assert(indnkeys);
+
+		/* Seek each attributes and add to a Bitmap */
+		for (int i = 0; i < indnkeys; i++)
+		{
+			AttrNumber localcol = idxrel->rd_index->indkey.values[i];
+			AttrNumber remotecol;
+
+			/*
+			 * XXX: Mark a relation as parallel-unsafe if it has expression
+			 * indexes because we cannot compute the hash value for the
+			 * dependency tracking. For safety, transactions that modify such
+			 * tables can wait for applications till the lastly dispatched
+			 * transaction is committed.
+			 */
+			if (!AttributeNumberIsValid(localcol))
+			{
+				entry->parallel_safe = LOGICALREP_PARALLEL_RESTRICTED;
+				suitable = false;
+				break;
+			}
+
+			remotecol = attrmap->attnums[AttrNumberGetAttrOffset(localcol)];
+
+			/*
+			 * Skip if the column does not exist on publisher node. In this
+			 * case the replicated tuples always have NULL or default value.
+			 */
+			if (remotecol < 0)
+			{
+				suitable = false;
+				break;
+			}
+
+			/* Checks are passed, remember the attribute */
+			indexkeys = bms_add_member(indexkeys, remotecol);
+		}
+
+		index_close(idxrel, AccessShareLock);
+
+		/*
+		 * Skip using the index if it is not suitable. This can happen if
+		 * 1) one of the columns does not exist on the publisher side, or
+		 * 2) there is an expression column.
+		 */
+		if (!suitable)
+		{
+			if (indexkeys)
+				bms_free(indexkeys);
+
+			continue;
+		}
+
+		/* This index is usable, store on memory */
+		if (indexkeys)
+		{
+			MemoryContext				oldctx;
+			LogicalRepSubscriberIdx	   *idxinfo;
+
+			oldctx = MemoryContextSwitchTo(LogicalRepRelMapContext);
+			idxinfo = palloc(sizeof(LogicalRepSubscriberIdx));
+			idxinfo->indexoid = idxoid;
+			idxinfo->indexkeys = bms_copy(indexkeys);
+			entry->local_unique_indexes =
+				lappend(entry->local_unique_indexes, idxinfo);
+
+			pfree(indexkeys);
+			MemoryContextSwitchTo(oldctx);
+		}
+	}
+
+	list_free(idxlist);
+}
+
 /*
  * Check all local triggers for the relation to see the parallelizability.
  *
@@ -369,7 +507,16 @@ logicalrep_rel_mark_updatable(LogicalRepRelMapEntry *entry)
 static void
 check_defined_triggers(LogicalRepRelMapEntry *entry)
 {
-	TriggerDesc *trigdesc = entry->localrel->trigdesc;
+	TriggerDesc *trigdesc;
+
+	/*
+	 * Skip if the parallelizability has already been checked. Possilble if the
+	 * relation has expression indexes.
+	 */
+	if (entry->parallel_safe != LOGICALREP_PARALLEL_UNKNOWN)
+		return;
+
+	trigdesc = entry->localrel->trigdesc;
 
 	/* Quick exit if triffer is not defined */
 	if (trigdesc == NULL)
@@ -410,7 +557,7 @@ check_defined_triggers(LogicalRepRelMapEntry *entry)
  * If the key is given, the corresponding entry is first searched in the hash
  * table and processed as in the above case. At the end, logical replication is
  * closed.
-  */
+ */
 void
 logicalrep_rel_load(LogicalRepRelMapEntry *entry, LogicalRepRelId remoteid,
 					LOCKMODE lockmode)
@@ -564,7 +711,11 @@ logicalrep_rel_load(LogicalRepRelMapEntry *entry, LogicalRepRelId remoteid,
 		 * tracking.
 		 */
 		if (am_leader_apply_worker())
+		{
+			entry->parallel_safe = LOGICALREP_PARALLEL_UNKNOWN;
+			collect_local_indexes(entry);
 			check_defined_triggers(entry);
+		}
 
 		entry->localrelvalid = true;
 	}
@@ -866,6 +1017,12 @@ logicalrep_partition_open(LogicalRepRelMapEntry *root,
 	entry->localindexoid = FindLogicalRepLocalIndex(partrel, remoterel,
 													entry->attrmap);
 
+	/*
+	 * TODO: Parallel apply does not support the parallel apply for now.
+	 * Just mark local indexes are collected.
+	 */
+	entry->local_unique_indexes_collected = true;
+
 	entry->localrelvalid = true;
 
 	return entry;
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index c1cf301c97b..39fe95746c7 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -548,9 +548,19 @@ typedef struct ApplySubXactData
 
 static ApplySubXactData subxact_data = {0, 0, InvalidTransactionId, NULL};
 
+/*
+ * Type of key used for dependency tracking.
+ */
+typedef enum LogicalRepKeyKind
+{
+	LOGICALREP_KEY_REPLICA_IDENTITY,
+	LOGICALREP_KEY_LOCAL_UNIQUE
+} LogicalRepKeyKind;
+
 typedef struct ReplicaIdentityKey
 {
 	Oid			relid;
+	LogicalRepKeyKind kind;
 	LogicalRepTupleData *data;
 } ReplicaIdentityKey;
 
@@ -710,7 +720,8 @@ static bool
 hash_replica_identity_compare(ReplicaIdentityKey *a, ReplicaIdentityKey *b)
 {
 	if (a->relid != b->relid ||
-		a->data->ncols != b->data->ncols)
+		a->data->ncols != b->data->ncols ||
+		a->kind != b->kind)
 		return false;
 
 	for (int i = 0; i < a->data->ncols; i++)
@@ -718,6 +729,9 @@ hash_replica_identity_compare(ReplicaIdentityKey *a, ReplicaIdentityKey *b)
 		if (a->data->colstatus[i] != b->data->colstatus[i])
 			return false;
 
+		if (a->data->colstatus[i] == LOGICALREP_COLUMN_NULL)
+			continue;
+
 		if (a->data->colvalues[i].len != b->data->colvalues[i].len)
 			return false;
 
@@ -847,6 +861,93 @@ check_and_append_xid_dependency(List *depends_on_xids,
 	return lappend_xid(depends_on_xids, *depends_on_xid);
 }
 
+/*
+ * Common function for checking dependency by using the key. Used by both
+ * check_dependency_on_replica_identity and check_dependency_on_local_key.
+ */
+static void
+check_dependency_by_key(ReplicaIdentityKey *key, TransactionId new_depended_xid,
+						List **depends_on_xids)
+{
+	ReplicaIdentityEntry   *rientry;
+	bool					found = false;
+	MemoryContext			oldctx;
+
+	oldctx = MemoryContextSwitchTo(ApplyContext);
+
+	if (TransactionIdIsValid(new_depended_xid))
+	{
+		rientry = replica_identity_insert(replica_identity_table, key,
+										  &found);
+
+		/*
+		 * Release the key built to search the entry, if the entry already
+		 * exists. Otherwise, initialize the remote_xid.
+		 */
+		if (found)
+		{
+			elog(DEBUG1,
+				 key->kind == LOGICALREP_KEY_REPLICA_IDENTITY ?
+				 "found conflicting replica identity change from %u" :
+				 "found conflicting local unique change from %u",
+				 rientry->remote_xid);
+
+			free_replica_identity_key(key);
+		}
+		else
+			rientry->remote_xid = InvalidTransactionId;
+	}
+	else
+	{
+		rientry = replica_identity_lookup(replica_identity_table, key);
+		free_replica_identity_key(key);
+	}
+
+	MemoryContextSwitchTo(oldctx);
+
+	/* Return if no entry found */
+	if (!rientry)
+		return;
+
+	Assert(!found || TransactionIdIsValid(rientry->remote_xid));
+
+	*depends_on_xids = check_and_append_xid_dependency(*depends_on_xids,
+													   &rientry->remote_xid,
+													   new_depended_xid);
+
+	/*
+	 * Remove the entry if it is registered for the streamed transactions. We
+	 * do not have to register an entry for them; The leader worker always
+	 * waits until the parallel worker finishes handling streamed transactions,
+	 * thus no need to consider the possiblity that upcoming parallel workers
+	 * would go ahead.
+	 */
+	if (TransactionIdIsValid(stream_xid) && !found)
+	{
+		free_replica_identity_key(rientry->keydata);
+		replica_identity_delete_item(replica_identity_table, rientry);
+	}
+
+	/*
+	 * Update the new depended xid into the entry if valid, the new xid could
+	 * be invalid if the transaction will be applied by the leader itself
+	 * which means all the changes will be committed before processing next
+	 * transaction, so no need to be depended on.
+	 */
+	else if (TransactionIdIsValid(new_depended_xid))
+		rientry->remote_xid = new_depended_xid;
+
+	/*
+	 * Remove the entry if the transaction has been committed and no new
+	 * dependency needs to be added.
+	 */
+	else if (!TransactionIdIsValid(rientry->remote_xid))
+	{
+		free_replica_identity_key(rientry->keydata);
+		replica_identity_delete_item(replica_identity_table, rientry);
+	}
+}
+
 /*
  * Check for dependencies on preceding transactions that modify the same key.
  * Returns the dependent transactions in 'depends_on_xids' and records the
@@ -861,10 +962,8 @@ check_dependency_on_replica_identity(Oid relid,
 	LogicalRepRelMapEntry *relentry;
 	LogicalRepTupleData *ridata;
 	ReplicaIdentityKey *rikey;
-	ReplicaIdentityEntry *rientry;
 	MemoryContext oldctx;
 	int			n_ri;
-	bool		found = false;
 
 	Assert(depends_on_xids);
 
@@ -930,75 +1029,122 @@ check_dependency_on_replica_identity(Oid relid,
 
 	rikey = palloc0_object(ReplicaIdentityKey);
 	rikey->relid = relid;
+	rikey->kind = LOGICALREP_KEY_REPLICA_IDENTITY;
 	rikey->data = ridata;
 
-	if (TransactionIdIsValid(new_depended_xid))
+	MemoryContextSwitchTo(oldctx);
+
+	check_dependency_by_key(rikey, new_depended_xid, depends_on_xids);
+}
+
+/*
+ * Mostly same as check_dependency_on_replica_identity() but for local unique
+ * indexes.
+ */
+static void
+check_dependency_on_local_key(Oid relid,
+							  LogicalRepTupleData *original_data,
+							  TransactionId new_depended_xid,
+							  List **depends_on_xids)
+{
+	LogicalRepRelMapEntry *relentry;
+	LogicalRepTupleData *ridata;
+	ReplicaIdentityKey *rikey;
+	MemoryContext oldctx;
+
+	Assert(depends_on_xids);
+
+	/* Search for existing entry */
+	relentry = logicalrep_get_relentry(relid);
+
+	Assert(relentry);
+
+	/*
+	 * Gather information for local indexes if not yet. We require to be in a
+	 * transaction state because system catalogs are read.
+	 */
+	if (!relentry->local_unique_indexes_collected)
 	{
-		rientry = replica_identity_insert(replica_identity_table, rikey,
-										  &found);
+		bool	needs_start = !IsTransactionOrTransactionBlock();
+
+		if (needs_start)
+			StartTransactionCommand();
+
+		logicalrep_rel_load(NULL, relid, AccessShareLock);
 
 		/*
-		 * Release the key built to search the entry, if the entry already
-		 * exists. Otherwise, initialize the remote_xid.
+		 * Close the transaction if we start here. We must not abort because it
+		 * would release all session-level locks, such as the stream lock, and
+		 * break the deadlock detection mechanism between LA and PA. The
+		 * outcome is the same regardless of the end status, since the
+		 * transaction did not modify any tuples.
 		 */
-		if (found)
-		{
-			elog(DEBUG1, "found conflicting replica identity change from %u",
-				 rientry->remote_xid);
+		if (needs_start)
+			CommitTransactionCommand();
 
-			free_replica_identity_key(rikey);
-		}
-		else
-			rientry->remote_xid = InvalidTransactionId;
+		Assert(relentry->local_unique_indexes_collected);
 	}
-	else
+
+	foreach_ptr(LogicalRepSubscriberIdx, idxinfo, relentry->local_unique_indexes)
 	{
-		rientry = replica_identity_lookup(replica_identity_table, rikey);
-		free_replica_identity_key(rikey);
-	}
+		int			columns = bms_num_members(idxinfo->indexkeys);
+		bool		suitable = true;
 
-	MemoryContextSwitchTo(oldctx);
+		Assert(columns);
 
-	/* Return if no entry found */
-	if (!rientry)
-		return;
+		for (int i = 0; i < original_data->ncols; i++)
+		{
+			if (!bms_is_member(i, idxinfo->indexkeys))
+				continue;
 
-	Assert(!found || TransactionIdIsValid(rientry->remote_xid));
+			/*
+			 * Skip if the column is not changed.
+			 *
+			 * XXX: NULL is allowed.
+			 */
+			if (original_data->colstatus[i] == LOGICALREP_COLUMN_UNCHANGED)
+			{
+				suitable = false;
+				break;
+			}
+		}
 
-	*depends_on_xids = check_and_append_xid_dependency(*depends_on_xids,
-													   &rientry->remote_xid,
-													   new_depended_xid);
+		if (!suitable)
+			continue;
 
-	/*
-	 * Remove the entry if it is registered for the streamed transactions. We
-	 * do not have to register an entry for them; The leader worker always
-	 * waits until the parallel worker finishes handling streamed transactions,
-	 * thus no need to consider the possiblity that upcoming parallel workers
-	 * would go ahead.
-	 */
-	if (TransactionIdIsValid(stream_xid) && !found)
-	{
-		free_replica_identity_key(rientry->keydata);
-		replica_identity_delete_item(replica_identity_table, rientry);
-	}
+		oldctx = MemoryContextSwitchTo(ApplyContext);
 
-	/*
-	 * Update the new depended xid into the entry if valid, the new xid could
-	 * be invalid if the transaction will be applied by the leader itself
-	 * which means all the changes will be committed before processing next
-	 * transaction, so no need to be depended on.
-	 */
-	else if (TransactionIdIsValid(new_depended_xid))
-		rientry->remote_xid = new_depended_xid;
+		/* Allocate space for replica identity values */
+		ridata = palloc0_object(LogicalRepTupleData);
+		ridata->colvalues = palloc0_array(StringInfoData, columns);
+		ridata->colstatus = palloc0_array(char, columns);
+		ridata->ncols = columns;
 
-	/*
-	 * Remove the entry if the transaction has been committed and no new
-	 * dependency needs to be added.
-	 */
-	else if (!TransactionIdIsValid(rientry->remote_xid))
-	{
-		free_replica_identity_key(rientry->keydata);
-		replica_identity_delete_item(replica_identity_table, rientry);
+		for (int i_original = 0, i_key = 0; i_original < original_data->ncols; i_original++)
+		{
+			if (!bms_is_member(i_original, idxinfo->indexkeys))
+				continue;
+
+			if (original_data->colstatus[i_original] != LOGICALREP_COLUMN_NULL)
+			{
+				StringInfo	original_colvalue = &original_data->colvalues[i_original];
+
+				initStringInfoExt(&ridata->colvalues[i_key], original_colvalue->len + 1);
+				appendStringInfoString(&ridata->colvalues[i_key], original_colvalue->data);
+			}
+
+			ridata->colstatus[i_key] = original_data->colstatus[i_original];
+			i_key++;
+		}
+
+		rikey = palloc0_object(ReplicaIdentityKey);
+		rikey->relid = relid;
+		rikey->kind = LOGICALREP_KEY_LOCAL_UNIQUE;
+		rikey->data = ridata;
+
+		MemoryContextSwitchTo(oldctx);
+
+		check_dependency_by_key(rikey, new_depended_xid, depends_on_xids);
 	}
 }
 
@@ -1181,6 +1327,9 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			check_dependency_on_replica_identity(relid, &newtup,
 												 new_depended_xid,
 												 &depends_on_xids);
+			check_dependency_on_local_key(relid, &newtup,
+										  new_depended_xid,
+										  &depends_on_xids);
 			check_dependency_for_parallel_safety(relid, new_depended_xid,
 												 &depends_on_xids);
 			break;
@@ -1194,6 +1343,9 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 				check_dependency_on_replica_identity(relid, &oldtup,
 													 new_depended_xid,
 													 &depends_on_xids);
+				check_dependency_on_local_key(relid, &oldtup,
+											  new_depended_xid,
+											  &depends_on_xids);
 				check_dependency_for_parallel_safety(relid, new_depended_xid,
 													 &depends_on_xids);
 			}
@@ -1201,6 +1353,9 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			check_dependency_on_replica_identity(relid, &newtup,
 												 new_depended_xid,
 												 &depends_on_xids);
+			check_dependency_on_local_key(relid, &newtup,
+										  new_depended_xid,
+										  &depends_on_xids);
 			check_dependency_for_parallel_safety(relid, new_depended_xid,
 												 &depends_on_xids);
 			break;
@@ -1210,6 +1365,9 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			check_dependency_on_replica_identity(relid, &oldtup,
 												 new_depended_xid,
 												 &depends_on_xids);
+			check_dependency_on_local_key(relid, &oldtup,
+										  new_depended_xid,
+										  &depends_on_xids);
 			check_dependency_for_parallel_safety(relid, new_depended_xid,
 												 &depends_on_xids);
 			break;
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index c4bfaaa67ac..ca7dee52b32 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -33,7 +33,6 @@
 #include "storage/procnumber.h"
 #include "utils/memutils.h"
 
-
 /*
  * One edge in the waits-for graph.
  *
diff --git a/src/include/replication/logicalrelation.h b/src/include/replication/logicalrelation.h
index e3d0df58620..9ac97fc4b38 100644
--- a/src/include/replication/logicalrelation.h
+++ b/src/include/replication/logicalrelation.h
@@ -16,6 +16,12 @@
 #include "catalog/index.h"
 #include "replication/logicalproto.h"
 
+typedef struct LogicalRepSubscriberIdx
+{
+	Oid			indexoid;	/* OID of the local key */
+	Bitmapset  *indexkeys;	/* Bitmap of key columns *on remote* */
+} LogicalRepSubscriberIdx;
+
 typedef struct LogicalRepRelMapEntry
 {
 	LogicalRepRelation remoterel;	/* key is remoterel.remoteid */
@@ -40,6 +46,10 @@ typedef struct LogicalRepRelMapEntry
 
 	TransactionId last_depended_xid;
 
+	/* Local unique indexes. Used for dependency tracking */
+	List	   *local_unique_indexes;
+	bool		local_unique_indexes_collected;
+
 	/*
 	 * Whether the relation can be applied in parallel or not. It is
 	 * distinglish whether defined triggers are the immutable or not.
@@ -51,6 +61,10 @@ typedef struct LogicalRepRelMapEntry
 	 * Note that we do not check the user-defined constraints here. PostgreSQL
 	 * has already assumed that CHECK constraints' conditions are immutable and
 	 * here follows the rule.
+	 *
+	 * XXX: Additonally, this can be false if the relation has expression
+	 * indexes. Because we cannot compute the hash value for the dependency
+	 * tracking.
 	 */
 	char		parallel_safe;
 } LogicalRepRelMapEntry;
diff --git a/src/test/subscription/t/050_parallel_apply.pl b/src/test/subscription/t/050_parallel_apply.pl
index 9254b85d350..337a598a38c 100644
--- a/src/test/subscription/t/050_parallel_apply.pl
+++ b/src/test/subscription/t/050_parallel_apply.pl
@@ -234,4 +234,47 @@ $node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset
 
 $h->query_safe("COMMIT;");
 
+# Ensure subscriber-local indexes are also used for the dependency tracking
+
+# Truncate the data for upcoming tests
+$node_publisher->safe_psql('postgres', "TRUNCATE TABLE regress_tab;");
+$node_publisher->wait_for_catchup('regress_sub');
+
+# Define an unique index on subscriber
+$node_subscriber->safe_psql('postgres',
+    "CREATE INDEX ON regress_tab (value);");
+
+# Attach an injection_point. Parallel workers would wait before the commit
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+# Insert a tuple on publisher. Parallel worker would wait at the injection
+# point
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (1, 'would conflict');");
+
+# Wait until the parallel worker enters the injection point.
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+$offset = -s $node_subscriber->logfile;
+
+# Insert tuples on publisher again. This transaction is would wait because all
+# parallel workers wait till the previously launched worker commits.
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (2, 'would not conflict');");
+
+# Verify the parallel worker waits for the transaction
+$str = $node_subscriber->wait_for_log(qr/wait for depended xid ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /wait for depended xid ([1-9][0-9]+)/;
+
+# Insert a conflicting tuple on publisher. Leader worker would detect the conflict
+# and wait for the transaction to commit.
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (3, 'would conflict');");
+
+# Verify the parallel worker waits for the same transaction
+$node_subscriber->wait_for_log(qr/wait for depended xid $xid/, $offset);
+
 done_testing();
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 80810793746..12479b64958 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -1637,6 +1637,7 @@ LogicalRepBeginData
 LogicalRepCommitData
 LogicalRepCommitPreparedTxnData
 LogicalRepCtxStruct
+LogicalRepKeyKind
 LogicalRepMsgType
 LogicalRepPartMapEntry
 LogicalRepPreparedTxnData
@@ -1646,6 +1647,7 @@ LogicalRepRelation
 LogicalRepRollbackPreparedTxnData
 LogicalRepSequenceInfo
 LogicalRepStreamAbortData
+LogicalRepSubscriberIdx
 LogicalRepTupleData
 LogicalRepTyp
 LogicalRepWorker
-- 
2.47.3



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

* RE: Parallel Apply
  2026-01-05 07:40 RE: Parallel Apply Hayato Kuroda (Fujitsu) <[email protected]>
@ 2026-01-30 11:18 ` Hayato Kuroda (Fujitsu) <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Hayato Kuroda (Fujitsu) @ 2026-01-30 11:18 UTC (permalink / raw)
  To: PostgreSQL Hackers <[email protected]>; +Cc: Tomas Vondra <[email protected]>; Amit Kapila <[email protected]>; 'Dilip Kumar' <[email protected]>; 'Andrei Lepikhov' <[email protected]>; Zhijie Hou (Fujitsu) <[email protected]>; 'wenhui qiu' <[email protected]>

Dear Hackers,

Here is a rebased version, plus 0009 patch.

0009 contains changes to handle dependencies based on the foreign keys. Without
this, parallel apply can violate foreign key constraints if referenced tuples
are committed after referencing tuples, leading to replication failures.

This patch extends the dependency hash to track dependencies via FKs. Since
referenced columns must be unique, information must be already stored to the
hash if they have been modified. Based on the point, the leader apply worker
checks to determine whether values in referencing columns have already been
registered or not if applying tuples refer some columns, and regards that there
is a dependency if found.

Best regards,
Hayato Kuroda
FUJITSU LIMITED



Attachments:

  [application/octet-stream] v8-0001-Introduce-new-type-of-logical-replication-message.patch (6.0K, ../../TY7PR01MB1455403538191EE4FA429106FF59FA@TY7PR01MB14554.jpnprd01.prod.outlook.com/2-v8-0001-Introduce-new-type-of-logical-replication-message.patch)
  download | inline diff:
From e121075c26b1a57c820d2ff57a840f8e3a4f383b Mon Sep 17 00:00:00 2001
From: Hayato Kuroda <[email protected]>
Date: Mon, 1 Dec 2025 10:37:27 +0900
Subject: [PATCH v8 1/9] Introduce new type of logical replication messages to
 track dependencies

This patch introduces two logical replication messages,
LOGICAL_REP_MSG_INTERNAL_DEPENDENCY and LOGICAL_REP_MSG_INTERNAL_RELATION.
Apart from other messages, they are not sent by walsnders; the leader worker
sends to parallel workers based on the needs.

LOGICAL_REP_MSG_INTERNAL_DEPENDENCY ensures that dependent transactions are
committed in the correct order. It has a list of transaction IDs that parallel
workers must wait for. The message type would be generated when the leader
detects a dependency between the current and other transactions, or just before
the COMMIT message. The latter one is used to preserve the commit ordering
between the publisher and the subscriber.

LOGICAL_REP_MSG_INTERNAL_RELATION is used to synchronize the relation
information between the leader and parallel workers. It has a list of relations
that the leader already knows, and parallel workers also update the relmap in
response to the message. This type of message is generated when the leader
allocates a new parallel worker to the transaction, or when the publisher sends
additional RELATION messages.

Author: Hou Zhijie <[email protected]>
Author: Hayato Kuroda <[email protected]>
---
 .../replication/logical/applyparallelworker.c | 16 ++++++
 src/backend/replication/logical/proto.c       |  4 ++
 src/backend/replication/logical/worker.c      | 49 +++++++++++++++++++
 src/include/replication/logicalproto.h        |  2 +
 src/include/replication/worker_internal.h     |  4 ++
 5 files changed, 75 insertions(+)

diff --git a/src/backend/replication/logical/applyparallelworker.c b/src/backend/replication/logical/applyparallelworker.c
index 8a01f16a2ca..748fd0c50a3 100644
--- a/src/backend/replication/logical/applyparallelworker.c
+++ b/src/backend/replication/logical/applyparallelworker.c
@@ -1645,3 +1645,19 @@ pa_xact_finish(ParallelApplyWorkerInfo *winfo, XLogRecPtr remote_lsn)
 
 	pa_free_worker(winfo);
 }
+
+/*
+ * Wait for the given transaction to finish.
+ */
+void
+pa_wait_for_depended_transaction(TransactionId xid)
+{
+	elog(DEBUG1, "wait for depended xid %u", xid);
+
+	for (;;)
+	{
+		/* XXX wait until given transaction is finished */
+	}
+
+	elog(DEBUG1, "finish waiting for depended xid %u", xid);
+}
diff --git a/src/backend/replication/logical/proto.c b/src/backend/replication/logical/proto.c
index 3950dd0cf46..66f930c8b06 100644
--- a/src/backend/replication/logical/proto.c
+++ b/src/backend/replication/logical/proto.c
@@ -1253,6 +1253,10 @@ logicalrep_message_type(LogicalRepMsgType action)
 			return "STREAM ABORT";
 		case LOGICAL_REP_MSG_STREAM_PREPARE:
 			return "STREAM PREPARE";
+		case LOGICAL_REP_MSG_INTERNAL_DEPENDENCY:
+			return "INTERNAL DEPENDENCY";
+		case LOGICAL_REP_MSG_INTERNAL_RELATION:
+			return "INTERNAL RELATION";
 	}
 
 	/*
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 32725c48623..43dce2b5622 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -629,6 +629,47 @@ static TransApplyAction get_transaction_apply_action(TransactionId xid,
 
 static void on_exit_clear_xact_state(int code, Datum arg);
 
+/*
+ * Handle internal dependency information.
+ *
+ * Wait for all transactions listed in the message to commit.
+ */
+static void
+apply_handle_internal_dependency(StringInfo s)
+{
+	int			nxids = pq_getmsgint(s, 4);
+
+	for (int i = 0; i < nxids; i++)
+	{
+		TransactionId xid = pq_getmsgint(s, 4);
+
+		pa_wait_for_depended_transaction(xid);
+	}
+}
+
+/*
+ * Handle internal relation information.
+ *
+ * Update all relation details in the relation map cache.
+ */
+static void
+apply_handle_internal_relation(StringInfo s)
+{
+	int			num_rels;
+
+	num_rels = pq_getmsgint(s, 4);
+
+	for (int i = 0; i < num_rels; i++)
+	{
+		LogicalRepRelation *rel = logicalrep_read_rel(s);
+
+		logicalrep_relmap_update(rel);
+
+		elog(DEBUG1, "parallel apply worker worker init relmap for %s",
+			 rel->relname);
+	}
+}
+
 /*
  * Form the origin name for the subscription.
  *
@@ -3868,6 +3909,14 @@ apply_dispatch(StringInfo s)
 			apply_handle_stream_prepare(s);
 			break;
 
+		case LOGICAL_REP_MSG_INTERNAL_RELATION:
+			apply_handle_internal_relation(s);
+			break;
+
+		case LOGICAL_REP_MSG_INTERNAL_DEPENDENCY:
+			apply_handle_internal_dependency(s);
+			break;
+
 		default:
 			ereport(ERROR,
 					(errcode(ERRCODE_PROTOCOL_VIOLATION),
diff --git a/src/include/replication/logicalproto.h b/src/include/replication/logicalproto.h
index 058a955e20c..9042470f500 100644
--- a/src/include/replication/logicalproto.h
+++ b/src/include/replication/logicalproto.h
@@ -75,6 +75,8 @@ typedef enum LogicalRepMsgType
 	LOGICAL_REP_MSG_STREAM_COMMIT = 'c',
 	LOGICAL_REP_MSG_STREAM_ABORT = 'A',
 	LOGICAL_REP_MSG_STREAM_PREPARE = 'p',
+	LOGICAL_REP_MSG_INTERNAL_DEPENDENCY = 'd',
+	LOGICAL_REP_MSG_INTERNAL_RELATION = 'i',
 } LogicalRepMsgType;
 
 /*
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index c1285fdd1bc..eb937be15ce 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -359,6 +359,8 @@ extern void pa_decr_and_wait_stream_block(void);
 extern void pa_xact_finish(ParallelApplyWorkerInfo *winfo,
 						   XLogRecPtr remote_lsn);
 
+extern void pa_wait_for_depended_transaction(TransactionId xid);
+
 #define isParallelApplyWorker(worker) ((worker)->in_use && \
 									   (worker)->type == WORKERTYPE_PARALLEL_APPLY)
 #define isTableSyncWorker(worker) ((worker)->in_use && \
@@ -366,6 +368,8 @@ extern void pa_xact_finish(ParallelApplyWorkerInfo *winfo,
 #define isSequenceSyncWorker(worker) ((worker)->in_use && \
 									  (worker)->type == WORKERTYPE_SEQUENCESYNC)
 
+#define PARALLEL_APPLY_INTERNAL_MESSAGE	'i'
+
 static inline bool
 am_tablesync_worker(void)
 {
-- 
2.47.3



  [application/octet-stream] v8-0002-Introduce-a-shared-hash-table-to-store-paralleliz.patch (9.1K, ../../TY7PR01MB1455403538191EE4FA429106FF59FA@TY7PR01MB14554.jpnprd01.prod.outlook.com/3-v8-0002-Introduce-a-shared-hash-table-to-store-paralleliz.patch)
  download | inline diff:
From 1c460af3d99d74771df3df05632096901bbc0c4a Mon Sep 17 00:00:00 2001
From: Hayato Kuroda <[email protected]>
Date: Mon, 1 Dec 2025 16:28:38 +0900
Subject: [PATCH v8 2/9] Introduce a shared hash table to store parallelized
 transactions

This hash table is used for ensuring that parallel workers wait until dependent
transactions are committed.

The shared hash table contains transaction IDs that the leader allocated to
parallel workers. The hash entries are inserted with a remote XID when the
leader bypasses remote transactions to parallel apply workers. Entries are
deleted when parallel workers are committed to corresponding transactions.

When the parallel worker tries to wait for other transactions, it checks the
hash table for the remote XIDs. The process can go ahead only when entries are
removed from the hash.

Author: Hou Zhijie <[email protected]>
Author: Hayato Kuroda <[email protected]>
---
 .../replication/logical/applyparallelworker.c | 100 +++++++++++++++++-
 .../utils/activity/wait_event_names.txt       |   1 +
 src/include/replication/worker_internal.h     |   4 +
 src/include/storage/lwlocklist.h              |   1 +
 src/tools/pgindent/typedefs.list              |   1 +
 5 files changed, 106 insertions(+), 1 deletion(-)

diff --git a/src/backend/replication/logical/applyparallelworker.c b/src/backend/replication/logical/applyparallelworker.c
index 748fd0c50a3..de2fda72818 100644
--- a/src/backend/replication/logical/applyparallelworker.c
+++ b/src/backend/replication/logical/applyparallelworker.c
@@ -218,12 +218,35 @@ typedef struct ParallelApplyWorkerEntry
 	ParallelApplyWorkerInfo *winfo;
 } ParallelApplyWorkerEntry;
 
+/* an entry in the parallelized_txns shared hash table */
+typedef struct ParallelizedTxnEntry
+{
+	TransactionId xid;			/* Hash key */
+} ParallelizedTxnEntry;
+
 /*
  * A hash table used to cache the state of streaming transactions being applied
  * by the parallel apply workers.
  */
 static HTAB *ParallelApplyTxnHash = NULL;
 
+/*
+ * A hash table used to track the parallelized transactions that could be
+ * depended on by other transactions.
+ */
+static dsa_area *parallel_apply_dsa_area = NULL;
+static dshash_table *parallelized_txns = NULL;
+
+/* parameters for the parallelized_txns shared hash table */
+static const dshash_parameters dsh_params = {
+	sizeof(TransactionId),
+	sizeof(ParallelizedTxnEntry),
+	dshash_memcmp,
+	dshash_memhash,
+	dshash_memcpy,
+	LWTRANCHE_PARALLEL_APPLY_DSA
+};
+
 /*
 * A list (pool) of active parallel apply workers. The information for
 * the new worker is added to the list after successfully launching it. The
@@ -257,6 +280,8 @@ static List *subxactlist = NIL;
 static void pa_free_worker_info(ParallelApplyWorkerInfo *winfo);
 static ParallelTransState pa_get_xact_state(ParallelApplyWorkerShared *wshared);
 static PartialFileSetState pa_get_fileset_state(void);
+static void pa_attach_parallelized_txn_hash(dsa_handle *pa_dsa_handle,
+											dshash_table_handle *pa_dshash_handle);
 
 /*
  * Returns true if it is OK to start a parallel apply worker, false otherwise.
@@ -334,6 +359,15 @@ pa_setup_dsm(ParallelApplyWorkerInfo *winfo)
 	shm_mq	   *mq;
 	Size		queue_size = DSM_QUEUE_SIZE;
 	Size		error_queue_size = DSM_ERROR_QUEUE_SIZE;
+	dsa_handle	parallel_apply_dsa_handle;
+	dshash_table_handle parallelized_txns_handle;
+
+	pa_attach_parallelized_txn_hash(&parallel_apply_dsa_handle,
+									&parallelized_txns_handle);
+
+	if (parallel_apply_dsa_handle == DSA_HANDLE_INVALID ||
+		parallelized_txns_handle == DSHASH_HANDLE_INVALID)
+		return false;
 
 	/*
 	 * Estimate how much shared memory we need.
@@ -369,6 +403,8 @@ pa_setup_dsm(ParallelApplyWorkerInfo *winfo)
 	pg_atomic_init_u32(&(shared->pending_stream_count), 0);
 	shared->last_commit_end = InvalidXLogRecPtr;
 	shared->fileset_state = FS_EMPTY;
+	shared->parallel_apply_dsa_handle = parallel_apply_dsa_handle;
+	shared->parallelized_txns_handle = parallelized_txns_handle;
 
 	shm_toc_insert(toc, PARALLEL_APPLY_KEY_SHARED, shared);
 
@@ -864,6 +900,8 @@ ParallelApplyWorkerMain(Datum main_arg)
 	shm_mq	   *mq;
 	shm_mq_handle *mqh;
 	shm_mq_handle *error_mqh;
+	dsa_handle	pa_dsa_handle;
+	dshash_table_handle pa_dshash_handle;
 	ReplOriginId originid;
 	int			worker_slot = DatumGetInt32(main_arg);
 	char		originname[NAMEDATALEN];
@@ -951,6 +989,8 @@ ParallelApplyWorkerMain(Datum main_arg)
 
 	InitializingApplyWorker = false;
 
+	pa_attach_parallelized_txn_hash(&pa_dsa_handle, &pa_dshash_handle);
+
 	/* Setup replication origin tracking. */
 	StartTransactionCommand();
 	ReplicationOriginNameForLogicalRep(MySubscription->oid, InvalidOid,
@@ -1646,6 +1686,51 @@ pa_xact_finish(ParallelApplyWorkerInfo *winfo, XLogRecPtr remote_lsn)
 	pa_free_worker(winfo);
 }
 
+/*
+ * Attach to the shared hash table for parallelized transactions.
+ */
+static void
+pa_attach_parallelized_txn_hash(dsa_handle *pa_dsa_handle,
+								dshash_table_handle *pa_dshash_handle)
+{
+	MemoryContext oldctx;
+
+	if (parallelized_txns)
+	{
+		Assert(parallel_apply_dsa_area);
+		*pa_dsa_handle = dsa_get_handle(parallel_apply_dsa_area);
+		*pa_dshash_handle = dshash_get_hash_table_handle(parallelized_txns);
+		return;
+	}
+
+	/* Be sure any local memory allocated by DSA routines is persistent. */
+	oldctx = MemoryContextSwitchTo(ApplyContext);
+
+	if (am_leader_apply_worker())
+	{
+		/* Initialize dynamic shared hash table for last-start times. */
+		parallel_apply_dsa_area = dsa_create(LWTRANCHE_PARALLEL_APPLY_DSA);
+		dsa_pin(parallel_apply_dsa_area);
+		dsa_pin_mapping(parallel_apply_dsa_area);
+		parallelized_txns = dshash_create(parallel_apply_dsa_area, &dsh_params, NULL);
+
+		/* Store handles in shared memory for other backends to use. */
+		*pa_dsa_handle = dsa_get_handle(parallel_apply_dsa_area);
+		*pa_dshash_handle = dshash_get_hash_table_handle(parallelized_txns);
+	}
+	else if (am_parallel_apply_worker())
+	{
+		/* Attach to existing dynamic shared hash table. */
+		parallel_apply_dsa_area = dsa_attach(MyParallelShared->parallel_apply_dsa_handle);
+		dsa_pin_mapping(parallel_apply_dsa_area);
+		parallelized_txns = dshash_attach(parallel_apply_dsa_area, &dsh_params,
+										  MyParallelShared->parallelized_txns_handle,
+										  NULL);
+	}
+
+	MemoryContextSwitchTo(oldctx);
+}
+
 /*
  * Wait for the given transaction to finish.
  */
@@ -1656,7 +1741,20 @@ pa_wait_for_depended_transaction(TransactionId xid)
 
 	for (;;)
 	{
-		/* XXX wait until given transaction is finished */
+		ParallelizedTxnEntry *txn_entry;
+
+		txn_entry = dshash_find(parallelized_txns, &xid, false);
+
+		/* The entry is removed only if the transaction is committed */
+		if (txn_entry == NULL)
+			break;
+
+		dshash_release_lock(parallelized_txns, txn_entry);
+
+		pa_lock_transaction(xid, AccessShareLock);
+		pa_unlock_transaction(xid, AccessShareLock);
+
+		CHECK_FOR_INTERRUPTS();
 	}
 
 	elog(DEBUG1, "finish waiting for depended xid %u", xid);
diff --git a/src/backend/utils/activity/wait_event_names.txt b/src/backend/utils/activity/wait_event_names.txt
index 5537a2d2530..34ee1a26df5 100644
--- a/src/backend/utils/activity/wait_event_names.txt
+++ b/src/backend/utils/activity/wait_event_names.txt
@@ -410,6 +410,7 @@ SubtransSLRU	"Waiting to access the sub-transaction SLRU cache."
 XactSLRU	"Waiting to access the transaction status SLRU cache."
 ParallelVacuumDSA	"Waiting for parallel vacuum dynamic shared memory allocation."
 AioUringCompletion	"Waiting for another process to complete IO via io_uring."
+ParallelApplyDSA	"Waiting for parallel apply dynamic shared memory allocation."
 
 # No "ABI_compatibility" region here as WaitEventLWLock has its own C code.
 
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index eb937be15ce..411e2c537c7 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -15,6 +15,7 @@
 #include "access/xlogdefs.h"
 #include "catalog/pg_subscription.h"
 #include "datatype/timestamp.h"
+#include "lib/dshash.h"
 #include "miscadmin.h"
 #include "replication/logicalrelation.h"
 #include "replication/walreceiver.h"
@@ -197,6 +198,9 @@ typedef struct ParallelApplyWorkerShared
 	 */
 	PartialFileSetState fileset_state;
 	FileSet		fileset;
+
+	dsa_handle	parallel_apply_dsa_handle;
+	dshash_table_handle parallelized_txns_handle;
 } ParallelApplyWorkerShared;
 
 /*
diff --git a/src/include/storage/lwlocklist.h b/src/include/storage/lwlocklist.h
index e94ebce95b9..1c138a73a7b 100644
--- a/src/include/storage/lwlocklist.h
+++ b/src/include/storage/lwlocklist.h
@@ -137,3 +137,4 @@ PG_LWLOCKTRANCHE(SUBTRANS_SLRU, SubtransSLRU)
 PG_LWLOCKTRANCHE(XACT_SLRU, XactSLRU)
 PG_LWLOCKTRANCHE(PARALLEL_VACUUM_DSA, ParallelVacuumDSA)
 PG_LWLOCKTRANCHE(AIO_URING_COMPLETION, AioUringCompletion)
+PG_LWLOCKTRANCHE(PARALLEL_APPLY_DSA, ParallelApplyDSA)
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 34374df0d67..d99c1996efd 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2095,6 +2095,7 @@ ParallelHashJoinBatch
 ParallelHashJoinBatchAccessor
 ParallelHashJoinState
 ParallelIndexScanDesc
+ParallelizedTxnEntry
 ParallelSlot
 ParallelSlotArray
 ParallelSlotResultHandler
-- 
2.47.3



  [application/octet-stream] v8-0003-Introduce-a-local-hash-table-to-store-replica-ide.patch (28.3K, ../../TY7PR01MB1455403538191EE4FA429106FF59FA@TY7PR01MB14554.jpnprd01.prod.outlook.com/4-v8-0003-Introduce-a-local-hash-table-to-store-replica-ide.patch)
  download | inline diff:
From 8b1d0353237933c49a8f53d7f199ace32a07e3c7 Mon Sep 17 00:00:00 2001
From: Hayato Kuroda <[email protected]>
Date: Mon, 1 Dec 2025 16:39:02 +0900
Subject: [PATCH v8 3/9] Introduce a local hash table to store replica
 identities

This local hash table on the leader is used for detecting dependencies between
transactions.

The hash contains the Replica Identity (RI) as a key and the remote XID that
modified the corresponding tuple. The hash entries are inserted when the leader
finds an RI from a replication message. Entries are deleted when transactions
committed by parallel workers are gathered, or the number of entries exceeds the
limit.

When the leader sends replication changes to parallel workers, it checks whether
other transactions have already used the RI associated with the change. If
something is found, the leader treats it as a dependent transaction and notifies
parallel workers to wait until it finishes via LOGICAL_REP_MSG_INTERNAL_DEPENDENCY.

Author: Hou Zhijie <[email protected]>
Author: Hayato Kuroda <[email protected]>
---
 .../replication/logical/applyparallelworker.c | 123 +++-
 src/backend/replication/logical/relation.c    |  24 +
 src/backend/replication/logical/worker.c      | 616 +++++++++++++++++-
 src/include/replication/logicalrelation.h     |   3 +
 src/include/replication/worker_internal.h     |   8 +-
 src/tools/pgindent/typedefs.list              |   3 +
 6 files changed, 774 insertions(+), 3 deletions(-)

diff --git a/src/backend/replication/logical/applyparallelworker.c b/src/backend/replication/logical/applyparallelworker.c
index de2fda72818..d462674b5cd 100644
--- a/src/backend/replication/logical/applyparallelworker.c
+++ b/src/backend/replication/logical/applyparallelworker.c
@@ -216,6 +216,7 @@ typedef struct ParallelApplyWorkerEntry
 {
 	TransactionId xid;			/* Hash key -- must be first */
 	ParallelApplyWorkerInfo *winfo;
+	XLogRecPtr	local_end;
 } ParallelApplyWorkerEntry;
 
 /* an entry in the parallelized_txns shared hash table */
@@ -504,7 +505,7 @@ pa_launch_parallel_worker(void)
  * streaming changes.
  */
 void
-pa_allocate_worker(TransactionId xid)
+pa_allocate_worker(TransactionId xid, bool stream_txn)
 {
 	bool		found;
 	ParallelApplyWorkerInfo *winfo = NULL;
@@ -545,7 +546,9 @@ pa_allocate_worker(TransactionId xid)
 
 	winfo->in_use = true;
 	winfo->serialize_changes = false;
+	winfo->stream_txn = stream_txn;
 	entry->winfo = winfo;
+	entry->local_end = InvalidXLogRecPtr;
 }
 
 /*
@@ -742,6 +745,73 @@ pa_process_spooled_messages_if_required(void)
 	return true;
 }
 
+/*
+ * Get the local end LSN for a transaction applied by a parallel apply worker.
+ *
+ * Set delete_entry to true if you intend to remove the transaction from the
+ * ParallelApplyTxnHash after collecting its LSN.
+ *
+ * If the parallel apply worker did not write any changes during the transaction
+ * application due to situations like update/delete_missing or a before trigger,
+ * the *skipped_write will be set to true.
+ */
+XLogRecPtr
+pa_get_last_commit_end(TransactionId xid, bool delete_entry, bool *skipped_write)
+{
+	bool		found;
+	ParallelApplyWorkerEntry *entry;
+	ParallelApplyWorkerInfo *winfo;
+
+	Assert(TransactionIdIsValid(xid));
+
+	if (skipped_write)
+		*skipped_write = false;
+
+	/* Find an entry for the requested transaction. */
+	entry = hash_search(ParallelApplyTxnHash, &xid, HASH_FIND, &found);
+
+	if (!found)
+		return InvalidXLogRecPtr;
+
+	/*
+	 * If worker info is NULL, it indicates that the worker has been reused
+	 * for handling other transactions. Consequently, the local end LSN has
+	 * already been collected and saved in entry->local_end.
+	 */
+	winfo = entry->winfo;
+	if (winfo == NULL)
+	{
+		if (delete_entry &&
+			!hash_search(ParallelApplyTxnHash, &xid, HASH_REMOVE, NULL))
+			elog(ERROR, "hash table corrupted");
+
+		if (skipped_write)
+			*skipped_write = XLogRecPtrIsInvalid(entry->local_end);
+
+		return entry->local_end;
+	}
+
+	/* Return InvalidXLogRecPtr if the transaction is still in progress */
+	if (pa_get_xact_state(winfo->shared) != PARALLEL_TRANS_FINISHED)
+		return InvalidXLogRecPtr;
+
+	/* Collect the local end LSN from the worker's shared memory area */
+	entry->local_end = winfo->shared->last_commit_end;
+	entry->winfo = NULL;
+
+	if (skipped_write)
+		*skipped_write = XLogRecPtrIsInvalid(entry->local_end);
+
+	elog(DEBUG1, "store local commit %X/%X end to txn entry: %u",
+		 LSN_FORMAT_ARGS(entry->local_end), xid);
+
+	if (delete_entry &&
+		!hash_search(ParallelApplyTxnHash, &xid, HASH_REMOVE, NULL))
+		elog(ERROR, "hash table corrupted");
+
+	return entry->local_end;
+}
+
 /*
  * Interrupt handler for main loop of parallel apply worker.
  */
@@ -1686,6 +1756,26 @@ pa_xact_finish(ParallelApplyWorkerInfo *winfo, XLogRecPtr remote_lsn)
 	pa_free_worker(winfo);
 }
 
+bool
+pa_transaction_committed(TransactionId xid)
+{
+	bool		found;
+	ParallelApplyWorkerEntry *entry;
+
+	Assert(TransactionIdIsValid(xid));
+
+	/* Find an entry for the requested transaction */
+	entry = hash_search(ParallelApplyTxnHash, &xid, HASH_FIND, &found);
+
+	if (!found)
+		return true;
+
+	if (!entry->winfo)
+		return true;
+
+	return pa_get_xact_state(entry->winfo->shared) == PARALLEL_TRANS_FINISHED;
+}
+
 /*
  * Attach to the shared hash table for parallelized transactions.
  */
@@ -1731,6 +1821,37 @@ pa_attach_parallelized_txn_hash(dsa_handle *pa_dsa_handle,
 	MemoryContextSwitchTo(oldctx);
 }
 
+/*
+ * Record in-progress transactions from the given list that are being depended
+ * on into the shared hash table.
+ */
+void
+pa_record_dependency_on_transactions(List *depends_on_xids)
+{
+	foreach_xid(xid, depends_on_xids)
+	{
+		bool		found;
+		ParallelApplyWorkerEntry *winfo_entry;
+		ParallelApplyWorkerInfo *winfo;
+		ParallelizedTxnEntry *txn_entry;
+
+		winfo_entry = hash_search(ParallelApplyTxnHash, &xid, HASH_FIND, &found);
+		winfo = winfo_entry->winfo;
+
+		txn_entry = dshash_find_or_insert(parallelized_txns, &xid, &found);
+
+		/*
+		 * If the transaction has been committed now, remove the entry,
+		 * otherwise the parallel apply worker will remove the entry once
+		 * committed the transaction.
+		 */
+		if (pa_get_xact_state(winfo->shared) == PARALLEL_TRANS_FINISHED)
+			dshash_delete_entry(parallelized_txns, txn_entry);
+		else
+			dshash_release_lock(parallelized_txns, txn_entry);
+	}
+}
+
 /*
  * Wait for the given transaction to finish.
  */
diff --git a/src/backend/replication/logical/relation.c b/src/backend/replication/logical/relation.c
index 0b1d80b5b0f..ab6313eb1bc 100644
--- a/src/backend/replication/logical/relation.c
+++ b/src/backend/replication/logical/relation.c
@@ -960,3 +960,27 @@ FindLogicalRepLocalIndex(Relation localrel, LogicalRepRelation *remoterel,
 
 	return InvalidOid;
 }
+
+/*
+ * Get the LogicalRepRelMapEntry corresponding to the given relid without
+ * opening the local relation.
+ */
+LogicalRepRelMapEntry *
+logicalrep_get_relentry(LogicalRepRelId remoteid)
+{
+	LogicalRepRelMapEntry *entry;
+	bool		found;
+
+	if (LogicalRepRelMap == NULL)
+		logicalrep_relmap_init();
+
+	/* Search for existing entry. */
+	entry = hash_search(LogicalRepRelMap, (void *) &remoteid,
+						HASH_FIND, &found);
+
+	if (!found)
+		elog(DEBUG1, "no relation map entry for remote relation ID %u",
+			 remoteid);
+
+	return entry;
+}
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 43dce2b5622..c2967caf1e5 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -303,6 +303,7 @@ typedef struct FlushPosition
 	dlist_node	node;
 	XLogRecPtr	local_end;
 	XLogRecPtr	remote_end;
+	TransactionId pa_remote_xid;
 } FlushPosition;
 
 static dlist_head lsn_mapping = DLIST_STATIC_INIT(lsn_mapping);
@@ -544,6 +545,49 @@ typedef struct ApplySubXactData
 
 static ApplySubXactData subxact_data = {0, 0, InvalidTransactionId, NULL};
 
+typedef struct ReplicaIdentityKey
+{
+	Oid			relid;
+	LogicalRepTupleData *data;
+} ReplicaIdentityKey;
+
+typedef struct ReplicaIdentityEntry
+{
+	ReplicaIdentityKey *keydata;
+	TransactionId remote_xid;
+
+	/* needed for simplehash */
+	uint32		hash;
+	char		status;
+} ReplicaIdentityEntry;
+
+#include "common/hashfn.h"
+
+static uint32 hash_replica_identity(ReplicaIdentityKey *key);
+static bool hash_replica_identity_compare(ReplicaIdentityKey *a,
+										  ReplicaIdentityKey *b);
+
+/* Define parameters for replica identity hash table code generation. */
+#define SH_PREFIX		replica_identity
+#define SH_ELEMENT_TYPE	ReplicaIdentityEntry
+#define SH_KEY_TYPE		ReplicaIdentityKey *
+#define	SH_KEY			keydata
+#define SH_HASH_KEY(tb, key)	hash_replica_identity(key)
+#define SH_EQUAL(tb, a, b)		hash_replica_identity_compare(a, b)
+#define SH_STORE_HASH
+#define SH_GET_HASH(tb, a) (a)->hash
+#define	SH_SCOPE		static inline
+#define SH_DECLARE
+#define SH_DEFINE
+#include "lib/simplehash.h"
+
+#define REPLICA_IDENTITY_INITIAL_SIZE 128
+#define REPLICA_IDENTITY_CLEANUP_THRESHOLD 1024
+
+static replica_identity_hash *replica_identity_table = NULL;
+
+static void write_internal_dependencies(StringInfo s, List *depends_on_xids);
+
 static inline void subxact_filename(char *path, Oid subid, TransactionId xid);
 static inline void changes_filename(char *path, Oid subid, TransactionId xid);
 
@@ -629,6 +673,546 @@ static TransApplyAction get_transaction_apply_action(TransactionId xid,
 
 static void on_exit_clear_xact_state(int code, Datum arg);
 
+static bool send_internal_dependencies(ParallelApplyWorkerInfo *winfo,
+									   StringInfo s);
+
+/*
+ * Compute the hash value for entries in the replica_identity_table.
+ */
+static uint32
+hash_replica_identity(ReplicaIdentityKey *key)
+{
+	int			i;
+	uint32		hashkey = 0;
+
+	hashkey = hash_combine(hashkey, hash_uint32(key->relid));
+
+	for (i = 0; i < key->data->ncols; i++)
+	{
+		uint32		hkey;
+
+		if (key->data->colstatus[i] == LOGICALREP_COLUMN_NULL)
+			continue;
+
+		hkey = hash_any((const unsigned char *) key->data->colvalues[i].data,
+						key->data->colvalues[i].len);
+		hashkey = hash_combine(hashkey, hkey);
+	}
+
+	return hashkey;
+}
+
+/*
+ * Compare two entries in the replica_identity_table.
+ */
+static bool
+hash_replica_identity_compare(ReplicaIdentityKey *a, ReplicaIdentityKey *b)
+{
+	if (a->relid != b->relid ||
+		a->data->ncols != b->data->ncols)
+		return false;
+
+	for (int i = 0; i < a->data->ncols; i++)
+	{
+		if (a->data->colstatus[i] != b->data->colstatus[i])
+			return false;
+
+		if (a->data->colvalues[i].len != b->data->colvalues[i].len)
+			return false;
+
+		if (strcmp(a->data->colvalues[i].data, b->data->colvalues[i].data))
+			return false;
+
+		elog(DEBUG1, "conflicting key %s", a->data->colvalues[i].data);
+	}
+
+	return true;
+}
+
+/*
+ * Free resources associated with a replica identity key.
+ */
+static void
+free_replica_identity_key(ReplicaIdentityKey *key)
+{
+	Assert(key);
+
+	pfree(key->data->colvalues);
+	pfree(key->data->colstatus);
+	pfree(key->data);
+	pfree(key);
+}
+
+/*
+ * Clean up hash table entries associated with the given transaction IDs.
+ */
+static void
+cleanup_replica_identity_table(List *committed_xid)
+{
+	replica_identity_iterator i;
+	ReplicaIdentityEntry *rientry;
+
+	if (!committed_xid)
+		return;
+
+	replica_identity_start_iterate(replica_identity_table, &i);
+	while ((rientry = replica_identity_iterate(replica_identity_table, &i)) != NULL)
+	{
+		if (!list_member_xid(committed_xid, rientry->remote_xid))
+			continue;
+
+		/* Clean up the hash entry for committed transaction */
+		free_replica_identity_key(rientry->keydata);
+		replica_identity_delete_item(replica_identity_table, rientry);
+	}
+}
+
+/*
+ * Check committed transactions and clean up corresponding entries in the hash
+ * table.
+ */
+static void
+cleanup_committed_replica_identity_entries(void)
+{
+	dlist_mutable_iter iter;
+	List	   *committed_xids = NIL;
+
+	dlist_foreach_modify(iter, &lsn_mapping)
+	{
+		FlushPosition *pos =
+			dlist_container(FlushPosition, node, iter.cur);
+		bool		skipped_write;
+
+		if (!TransactionIdIsValid(pos->pa_remote_xid) ||
+			!XLogRecPtrIsInvalid(pos->local_end))
+			continue;
+
+		pos->local_end = pa_get_last_commit_end(pos->pa_remote_xid, true,
+												&skipped_write);
+
+		elog(DEBUG1,
+			 "got commit end from parallel apply worker, "
+			 "txn: %u, remote_end %X/%X, local_end %X/%X",
+			 pos->pa_remote_xid, LSN_FORMAT_ARGS(pos->remote_end),
+			 LSN_FORMAT_ARGS(pos->local_end));
+
+		if (!skipped_write && XLogRecPtrIsInvalid(pos->local_end))
+			continue;
+
+		committed_xids = lappend_xid(committed_xids, pos->pa_remote_xid);
+	}
+
+	/* cleanup the entries for committed transactions */
+	cleanup_replica_identity_table(committed_xids);
+}
+
+/*
+ * Append a transaction dependency, excluding duplicates and committed
+ * transactions.
+ */
+static List *
+check_and_append_xid_dependency(List *depends_on_xids,
+								TransactionId *depends_on_xid,
+								TransactionId current_xid)
+{
+	Assert(depends_on_xid);
+
+	if (!TransactionIdIsValid(*depends_on_xid))
+		return depends_on_xids;
+
+	if (TransactionIdEquals(*depends_on_xid, current_xid))
+		return depends_on_xids;
+
+	if (list_member_xid(depends_on_xids, *depends_on_xid))
+		return depends_on_xids;
+
+	/*
+	 * Return and reset the xid if the transaction has been committed.
+	 */
+	if (pa_transaction_committed(*depends_on_xid))
+	{
+		*depends_on_xid = InvalidTransactionId;
+		return depends_on_xids;
+	}
+
+	return lappend_xid(depends_on_xids, *depends_on_xid);
+}
+
+/*
+ * Check for dependencies on preceding transactions that modify the same key.
+ * Returns the dependent transactions in 'depends_on_xids' and records the
+ * current change.
+ */
+static void
+check_dependency_on_replica_identity(Oid relid,
+									 LogicalRepTupleData *original_data,
+									 TransactionId new_depended_xid,
+									 List **depends_on_xids)
+{
+	LogicalRepRelMapEntry *relentry;
+	LogicalRepTupleData *ridata;
+	ReplicaIdentityKey *rikey;
+	ReplicaIdentityEntry *rientry;
+	MemoryContext oldctx;
+	int			n_ri;
+	bool		found = false;
+
+	Assert(depends_on_xids);
+
+	/* Search for existing entry */
+	relentry = logicalrep_get_relentry(relid);
+
+	Assert(relentry);
+
+	/*
+	 * First search whether any previous transaction has affected the whole
+	 * table e.g., truncate or schema change from publisher.
+	 */
+	*depends_on_xids = check_and_append_xid_dependency(*depends_on_xids,
+													   &relentry->last_depended_xid,
+													   new_depended_xid);
+
+	n_ri = bms_num_members(relentry->remoterel.attkeys);
+
+	/*
+	 * Return if there are no replica identity columns, indicating that the
+	 * remote relation has neither a replica identity key nor is marked as
+	 * replica identity full.
+	 */
+	if (!n_ri)
+		return;
+
+	/* Check if the RI key value of the tuple is invalid */
+	for (int i = 0; i < original_data->ncols; i++)
+	{
+		if (!bms_is_member(i, relentry->remoterel.attkeys))
+			continue;
+
+		/*
+		 * Return if RI key is NULL or is explicitly marked unchanged. The key
+		 * value could be NULL in the new tuple of a update opertaion which
+		 * means the RI key is not updated.
+		 */
+		if (original_data->colstatus[i] == LOGICALREP_COLUMN_NULL ||
+			original_data->colstatus[i] == LOGICALREP_COLUMN_UNCHANGED)
+			return;
+	}
+
+	oldctx = MemoryContextSwitchTo(ApplyContext);
+
+	/* Allocate space for replica identity values */
+	ridata = palloc0_object(LogicalRepTupleData);
+	ridata->colvalues = palloc0_array(StringInfoData, n_ri);
+	ridata->colstatus = palloc0_array(char, n_ri);
+	ridata->ncols = n_ri;
+
+	for (int i_original = 0, i_ri = 0; i_original < original_data->ncols; i_original++)
+	{
+		StringInfo	original_colvalue = &original_data->colvalues[i_original];
+
+		if (!bms_is_member(i_original, relentry->remoterel.attkeys))
+			continue;
+
+		initStringInfoExt(&ridata->colvalues[i_ri], original_colvalue->len + 1);
+		appendStringInfoString(&ridata->colvalues[i_ri], original_colvalue->data);
+		ridata->colstatus[i_ri] = original_data->colstatus[i_original];
+		i_ri++;
+	}
+
+	rikey = palloc0_object(ReplicaIdentityKey);
+	rikey->relid = relid;
+	rikey->data = ridata;
+
+	if (TransactionIdIsValid(new_depended_xid))
+	{
+		rientry = replica_identity_insert(replica_identity_table, rikey,
+										  &found);
+
+		/*
+		 * Release the key built to search the entry, if the entry already
+		 * exists. Otherwise, initialize the remote_xid.
+		 */
+		if (found)
+		{
+			elog(DEBUG1, "found conflicting replica identity change from %u",
+				 rientry->remote_xid);
+
+			free_replica_identity_key(rikey);
+		}
+		else
+			rientry->remote_xid = InvalidTransactionId;
+	}
+	else
+	{
+		rientry = replica_identity_lookup(replica_identity_table, rikey);
+		free_replica_identity_key(rikey);
+	}
+
+	MemoryContextSwitchTo(oldctx);
+
+	/* Return if no entry found */
+	if (!rientry)
+		return;
+
+	Assert(!found || TransactionIdIsValid(rientry->remote_xid));
+
+	*depends_on_xids = check_and_append_xid_dependency(*depends_on_xids,
+													   &rientry->remote_xid,
+													   new_depended_xid);
+
+	/*
+	 * Update the new depended xid into the entry if valid, the new xid could
+	 * be invalid if the transaction will be applied by the leader itself
+	 * which means all the changes will be committed before processing next
+	 * transaction, so no need to be depended on.
+	 */
+	if (TransactionIdIsValid(new_depended_xid))
+		rientry->remote_xid = new_depended_xid;
+
+	/*
+	 * Remove the entry if the transaction has been committed and no new
+	 * dependency needs to be added.
+	 */
+	else if (!TransactionIdIsValid(rientry->remote_xid))
+	{
+		free_replica_identity_key(rientry->keydata);
+		replica_identity_delete_item(replica_identity_table, rientry);
+	}
+}
+
+/*
+ * Check for preceding transactions that involve insert, delete, or update
+ * operations on the specified table, and return them in 'depends_on_xids'.
+ */
+static void
+find_all_dependencies_on_rel(LogicalRepRelId relid, TransactionId new_depended_xid,
+							 List **depends_on_xids)
+{
+	replica_identity_iterator i;
+	ReplicaIdentityEntry *rientry;
+
+	Assert(depends_on_xids);
+
+	replica_identity_start_iterate(replica_identity_table, &i);
+	while ((rientry = replica_identity_iterate(replica_identity_table, &i)) != NULL)
+	{
+		Assert(TransactionIdIsValid(rientry->remote_xid));
+
+		if (rientry->keydata->relid != relid)
+			continue;
+
+		/* Clean up the hash entry for committed transaction while on it */
+		if (pa_transaction_committed(rientry->remote_xid))
+		{
+			free_replica_identity_key(rientry->keydata);
+			replica_identity_delete_item(replica_identity_table, rientry);
+
+			continue;
+		}
+
+		*depends_on_xids = check_and_append_xid_dependency(*depends_on_xids,
+														   &rientry->remote_xid,
+														   new_depended_xid);
+	}
+}
+
+/*
+ * Check for any preceding transactions that affect the given table and returns
+ * them in 'depends_on_xids'.
+ */
+static void
+check_dependency_on_rel(LogicalRepRelId relid, TransactionId new_depended_xid,
+						List **depends_on_xids)
+{
+	LogicalRepRelMapEntry *relentry;
+
+	Assert(depends_on_xids);
+
+	find_all_dependencies_on_rel(relid, new_depended_xid, depends_on_xids);
+
+	/* Search for existing entry */
+	relentry = logicalrep_get_relentry(relid);
+
+	/*
+	 * The relentry has not been initialized yet, indicating no change has
+	 * been applide yet.
+	 */
+	if (!relentry)
+		return;
+
+	*depends_on_xids = check_and_append_xid_dependency(*depends_on_xids,
+													   &relentry->last_depended_xid,
+													   new_depended_xid);
+
+	if (TransactionIdIsValid(new_depended_xid))
+		relentry->last_depended_xid = new_depended_xid;
+}
+
+/*
+ * Check dependencies related to the current change by determining if the
+ * modification impacts the same row or table as another ongoing transaction. If
+ * needed, instruct parallel apply workers to wait for these preceding
+ * transactions to complete.
+ *
+ * Simultaneously, track the dependency for the current change to ensure that
+ * subsequent transactions address this dependency.
+ */
+static void
+handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
+							TransactionId new_depended_xid,
+							ParallelApplyWorkerInfo *winfo)
+{
+	LogicalRepRelId relid;
+	LogicalRepTupleData oldtup;
+	LogicalRepTupleData newtup;
+	LogicalRepRelation *rel;
+	List	   *depends_on_xids = NIL;
+	List	   *remote_relids;
+	bool		has_oldtup = false;
+	bool		cascade = false;
+	bool		restart_seqs = false;
+	StringInfoData dependencies;
+
+	/*
+	 * Parse the consume data using a local copy instead of directly consuming
+	 * the given remote change as the caller may also read the data from the
+	 * remote message.
+	 */
+	StringInfoData change = *s;
+
+	/* Compute dependency only for non-streaming transaction */
+	if (in_streamed_transaction || (winfo && winfo->stream_txn))
+		return;
+
+	/* Only the leader checks dependencies and schedules the parallel apply */
+	if (!am_leader_apply_worker())
+		return;
+
+	if (!replica_identity_table)
+		replica_identity_table = replica_identity_create(ApplyContext,
+														 REPLICA_IDENTITY_INITIAL_SIZE,
+														 NULL);
+
+	if (replica_identity_table->members >= REPLICA_IDENTITY_CLEANUP_THRESHOLD)
+		cleanup_committed_replica_identity_entries();
+
+	switch (action)
+	{
+		case LOGICAL_REP_MSG_INSERT:
+			relid = logicalrep_read_insert(&change, &newtup);
+			check_dependency_on_replica_identity(relid, &newtup,
+												 new_depended_xid,
+												 &depends_on_xids);
+			break;
+
+		case LOGICAL_REP_MSG_UPDATE:
+			relid = logicalrep_read_update(&change, &has_oldtup, &oldtup,
+										   &newtup);
+
+			if (has_oldtup)
+				check_dependency_on_replica_identity(relid, &oldtup,
+													 new_depended_xid,
+													 &depends_on_xids);
+
+			check_dependency_on_replica_identity(relid, &newtup,
+												 new_depended_xid,
+												 &depends_on_xids);
+			break;
+
+		case LOGICAL_REP_MSG_DELETE:
+			relid = logicalrep_read_delete(&change, &oldtup);
+			check_dependency_on_replica_identity(relid, &oldtup,
+												 new_depended_xid,
+												 &depends_on_xids);
+			break;
+
+		case LOGICAL_REP_MSG_TRUNCATE:
+			remote_relids = logicalrep_read_truncate(&change, &cascade,
+													 &restart_seqs);
+
+			/*
+			 * Truncate affects all rows in a table, so the current
+			 * transaction should wait for all preceding transactions that
+			 * modified the same table.
+			 */
+			foreach_int(truncated_relid, remote_relids)
+				check_dependency_on_rel(truncated_relid, new_depended_xid,
+										&depends_on_xids);
+
+			break;
+
+		case LOGICAL_REP_MSG_RELATION:
+			rel = logicalrep_read_rel(&change);
+
+			/*
+			 * The replica identity key could be changed, making existing
+			 * entries in the replica identity invalid. In this case, parallel
+			 * apply is not allowed on this specific table until all running
+			 * transactions that modified it have finished.
+			 */
+			check_dependency_on_rel(rel->remoteid, new_depended_xid,
+									&depends_on_xids);
+			break;
+
+		case LOGICAL_REP_MSG_TYPE:
+		case LOGICAL_REP_MSG_MESSAGE:
+
+			/*
+			 * Type updates accompany relation updates, so dependencies have
+			 * already been checked during relation updates. Logical messages
+			 * do not conflict with any changes, so they can be ignored.
+			 */
+			break;
+
+		default:
+			Assert(false);
+			break;
+	}
+
+	if (!depends_on_xids)
+		return;
+
+	/*
+	 * Notify the transactions that they are dependent on the current
+	 * transaction.
+	 */
+	pa_record_dependency_on_transactions(depends_on_xids);
+
+	/*
+	 * If the leader applies the transaction itself, start waiting for
+	 * transactions that depend on the current transaction immediately.
+	 */
+	if (winfo == NULL)
+	{
+		foreach_xid(xid, depends_on_xids)
+			pa_wait_for_depended_transaction(xid);
+
+		return;
+	}
+
+	initStringInfo(&dependencies);
+
+	/* Build the dependency message used to send to parallel apply worker */
+	write_internal_dependencies(&dependencies, depends_on_xids);
+
+	(void) send_internal_dependencies(winfo, &dependencies);
+}
+
+/*
+ * Write internal dependency information to the output for the parallel apply
+ * worker.
+ */
+static void
+write_internal_dependencies(StringInfo s, List *depends_on_xids)
+{
+	pq_sendbyte(s, PARALLEL_APPLY_INTERNAL_MESSAGE);
+	pq_sendbyte(s, LOGICAL_REP_MSG_INTERNAL_DEPENDENCY);
+	pq_sendint32(s, list_length(depends_on_xids));
+
+	foreach_xid(xid, depends_on_xids)
+		pq_sendint32(s, xid);
+}
+
 /*
  * Handle internal dependency information.
  *
@@ -826,7 +1410,10 @@ handle_streamed_transaction(LogicalRepMsgType action, StringInfo s)
 
 	/* not in streaming mode */
 	if (apply_action == TRANS_LEADER_APPLY)
+	{
+		handle_dependency_on_change(action, s, InvalidTransactionId, winfo);
 		return false;
+	}
 
 	Assert(TransactionIdIsValid(stream_xid));
 
@@ -1268,6 +1855,33 @@ apply_handle_begin(StringInfo s)
 	pgstat_report_activity(STATE_RUNNING, NULL);
 }
 
+/*
+ * Send an INTERNAL_DEPENDENCY message to a parallel apply worker.
+ *
+ * Returns false if we switched to the serialize mode to send the message,
+ * true otherwise.
+ */
+static bool
+send_internal_dependencies(ParallelApplyWorkerInfo *winfo, StringInfo s)
+{
+	Assert(s->data[0] == PARALLEL_APPLY_INTERNAL_MESSAGE);
+	Assert(s->data[1] == LOGICAL_REP_MSG_INTERNAL_DEPENDENCY);
+
+	if (!winfo->serialize_changes)
+	{
+		if (pa_send_data(winfo, s->len, s->data))
+			return true;
+
+		pa_switch_to_partial_serialize(winfo, true);
+	}
+
+	/* Skip writing the first internal message flag */
+	s->cursor++;
+	stream_write_change(LOGICAL_REP_MSG_INTERNAL_DEPENDENCY, s);
+
+	return false;
+}
+
 /*
  * Handle COMMIT message.
  *
@@ -1795,7 +2409,7 @@ apply_handle_stream_start(StringInfo s)
 
 	/* Try to allocate a worker for the streaming transaction. */
 	if (first_segment)
-		pa_allocate_worker(stream_xid);
+		pa_allocate_worker(stream_xid, true);
 
 	apply_action = get_transaction_apply_action(stream_xid, &winfo);
 
diff --git a/src/include/replication/logicalrelation.h b/src/include/replication/logicalrelation.h
index efe0f9d6031..f9aa9e59a6d 100644
--- a/src/include/replication/logicalrelation.h
+++ b/src/include/replication/logicalrelation.h
@@ -37,6 +37,8 @@ typedef struct LogicalRepRelMapEntry
 	/* Sync state. */
 	char		state;
 	XLogRecPtr	statelsn;
+
+	TransactionId last_depended_xid;
 } LogicalRepRelMapEntry;
 
 extern void logicalrep_relmap_update(LogicalRepRelation *remoterel);
@@ -50,5 +52,6 @@ extern void logicalrep_rel_close(LogicalRepRelMapEntry *rel,
 								 LOCKMODE lockmode);
 extern bool IsIndexUsableForReplicaIdentityFull(Relation idxrel, AttrMap *attrmap);
 extern Oid	GetRelationIdentityOrPK(Relation rel);
+extern LogicalRepRelMapEntry *logicalrep_get_relentry(LogicalRepRelId remoteid);
 
 #endif							/* LOGICALRELATION_H */
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index 411e2c537c7..1dbec625c81 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -235,6 +235,8 @@ typedef struct ParallelApplyWorkerInfo
 	 */
 	bool		in_use;
 
+	bool		stream_txn;
+
 	ParallelApplyWorkerShared *shared;
 } ParallelApplyWorkerInfo;
 
@@ -332,8 +334,10 @@ extern void apply_error_callback(void *arg);
 extern void set_apply_error_context_origin(char *originname);
 
 /* Parallel apply worker setup and interactions */
-extern void pa_allocate_worker(TransactionId xid);
+extern void pa_allocate_worker(TransactionId xid, bool stream_txn);
 extern ParallelApplyWorkerInfo *pa_find_worker(TransactionId xid);
+extern XLogRecPtr pa_get_last_commit_end(TransactionId xid, bool delete_entry,
+										 bool *skipped_write);
 extern void pa_detach_all_error_mq(void);
 
 extern bool pa_send_data(ParallelApplyWorkerInfo *winfo, Size nbytes,
@@ -362,6 +366,8 @@ extern void pa_decr_and_wait_stream_block(void);
 
 extern void pa_xact_finish(ParallelApplyWorkerInfo *winfo,
 						   XLogRecPtr remote_lsn);
+extern bool pa_transaction_committed(TransactionId xid);
+extern void pa_record_dependency_on_transactions(List *depends_on_xids);
 
 extern void pa_wait_for_depended_transaction(TransactionId xid);
 
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index d99c1996efd..05936d61a54 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2582,6 +2582,8 @@ ReplOriginXactState
 ReplaceVarsFromTargetList_context
 ReplaceVarsNoMatchOption
 ReplaceWrapOption
+ReplicaIdentityEntry
+ReplicaIdentityKey
 ReplicaIdentityStmt
 ReplicationKind
 ReplicationSlot
@@ -2593,6 +2595,7 @@ ReplicationSlotPersistentData
 ReplicationState
 ReplicationStateCtl
 ReplicationStateOnDisk
+replica_identity_hash
 ResTarget
 ReservoirState
 ReservoirStateData
-- 
2.47.3



  [application/octet-stream] v8-0004-Parallel-apply-non-streaming-transactions.patch (51.3K, ../../TY7PR01MB1455403538191EE4FA429106FF59FA@TY7PR01MB14554.jpnprd01.prod.outlook.com/5-v8-0004-Parallel-apply-non-streaming-transactions.patch)
  download | inline diff:
From b906303922a0663a53677ca8c1b4082f10a5ce0b Mon Sep 17 00:00:00 2001
From: Hayato Kuroda <[email protected]>
Date: Mon, 1 Dec 2025 12:28:29 +0900
Subject: [PATCH v8 4/9] Parallel apply non-streaming transactions

--
Basic design
--

The leader worker assigns each non-streaming transaction to a parallel apply
worker. Before dispatching changes to a parallel worker, the leader verifies if
the current modification affects the same row (identitied by replica identity
key) as another ongoing transaction. If so, the leader sends a list of dependent
transaction IDs to the parallel worker, indicating that the parallel apply
worker must wait for these transactions to commit before proceeding.

Each parallel apply worker records the local end LSN of the transaction it
applies in shared memory. Subsequently, the leader gathers these local end LSNs
and logs them in the local 'lsn_mapping' for verifying whether they have been
flushed to disk (following the logic in get_flush_position()).

If no parallel apply worker is available, the leader will apply the transaction
independently.

For further details, please refer to the following:

--
dedendency tracking
--

The leader maintains a local hash table, using the remote change's replica
identity column values and relid as keys, with remote transaction IDs as values.
Before sending changes to the parallel apply worker, the leader computes a hash
using RI key values and the relid of the current change to search the hash
table. If an existing entry is found, the leader first updates the hash entry
with the receiving remote xid then tells the parallel worker to wait for it.

If the remote relation lacks a replica identity (RI), it indicates that only
INSERT can be replicated for this table. In such cases, the leader skips
dependency checks, allowing the parallel apply worker to proceed with applying
changes without delay. This is because the only potential conflict could happen
is related to the local unique key or foreign key, which that is yet to be
implemented (see TODO - dependency on local unique key, foreign key.).

In cases of TRUNCATE or remote schema changes affecting the entire table, the
leader retrieves all remote xids touching the same table (via sequential scans
of the hash table) and tells the parallel worker to wait for those transactions
to commit.

Hash entries are cleaned up once the transaction corresponding to the remote xid
in the entry has been committed. Clean-up typically occurs when collecting the
flush position of each transaction, but is forced if the hash table exceeds a
set threshold.

--
dedendency waiting
--

If a transaction is relied upon by others, the leader adds its xid to a shared
hash table. The shared hash table entry is cleared by the parallel apply worker
upon completing the transaction. Workers needing to wait for a transaction check
the shared hash table entry; if present, they lock the transaction ID (using
pa_lock_transaction). If absent, it indicates the transaction has been
committed, negating the need to wait.

--
commit order
--

There is a case where columns have no foreign or primary keys, and integrity is
maintained at the application layer. In this case, the above RI mechanism cannot
detect any dependencies. For safety reasons, parallel apply workers preserve the
commit ordering done on the publisher side. This is done by the leader worker
caching the lastly dispatched transaction ID and adding a dependency between it
and the currently dispatching one.
---
 .../replication/logical/applyparallelworker.c | 339 ++++++++++++++++--
 src/backend/replication/logical/proto.c       |  38 ++
 src/backend/replication/logical/relation.c    |  31 ++
 src/backend/replication/logical/worker.c      | 304 ++++++++++++++--
 src/include/replication/logicalproto.h        |   2 +
 src/include/replication/logicalrelation.h     |   2 +
 src/include/replication/worker_internal.h     |  11 +-
 src/test/subscription/meson.build             |   1 +
 src/test/subscription/t/001_rep_changes.pl    |   2 +
 src/test/subscription/t/010_truncate.pl       |   2 +-
 src/test/subscription/t/015_stream.pl         |   8 +-
 src/test/subscription/t/026_stats.pl          |   1 +
 src/test/subscription/t/027_nosuperuser.pl    |   1 +
 src/test/subscription/t/050_parallel_apply.pl | 130 +++++++
 src/tools/pgindent/typedefs.list              |   2 +
 15 files changed, 800 insertions(+), 74 deletions(-)
 create mode 100644 src/test/subscription/t/050_parallel_apply.pl

diff --git a/src/backend/replication/logical/applyparallelworker.c b/src/backend/replication/logical/applyparallelworker.c
index d462674b5cd..3396ee1cdf1 100644
--- a/src/backend/replication/logical/applyparallelworker.c
+++ b/src/backend/replication/logical/applyparallelworker.c
@@ -14,6 +14,9 @@
  * ParallelApplyWorkerInfo which is required so the leader worker and parallel
  * apply workers can communicate with each other.
  *
+ * Streaming transactions
+ * ======================
+ *
  * The parallel apply workers are assigned (if available) as soon as xact's
  * first stream is received for subscriptions that have set their 'streaming'
  * option as parallel. The leader apply worker will send changes to this new
@@ -152,6 +155,33 @@
  * session-level locks because both locks could be acquired outside the
  * transaction, and the stream lock in the leader needs to persist across
  * transaction boundaries i.e. until the end of the streaming transaction.
+ *
+ * Non-streaming transactions
+ * ======================
+ * The handling is similar to streaming transactions, but including few
+ * differences:
+ *
+ * Transaction dependency
+ * ----------------------
+ * Before dispatching changes to a parallel worker, the leader verifies if the
+ * current modification affects the same row (identitied by replica identity
+ * key) as another ongoing transaction (see handle_dependency_on_change for
+ * details). If so, the leader sends a list of dependent transaction IDs to the
+ * parallel worker, indicating that the parallel apply worker must wait for
+ * these transactions to commit before proceeding.
+ *
+ * Commit order
+ * ------------
+ * There is a case where columns have no foreign or primary keys, and integrity
+ * is maintained at the application layer. In this case, the above RI mechanism
+ * cannot detect any dependencies. For safety reasons, parallel apply workers
+ * preserve the commit ordering done on the publisher side. This is done by the
+ * leader worker caching the lastly dispatched transaction ID and adding a
+ * dependency between it and the currently dispatching one.
+ * We can extend the parallel apply worker to allow out-of-order commits in the
+ * future: At least we must use a new mechanism to track replication progress
+ * in out-of-order commits. Then we can stop caching the transaction ID and
+ * adding the dependency.
  *-------------------------------------------------------------------------
  */
 
@@ -283,6 +313,7 @@ static ParallelTransState pa_get_xact_state(ParallelApplyWorkerShared *wshared);
 static PartialFileSetState pa_get_fileset_state(void);
 static void pa_attach_parallelized_txn_hash(dsa_handle *pa_dsa_handle,
 											dshash_table_handle *pa_dshash_handle);
+static void write_internal_relation(StringInfo s, LogicalRepRelation *rel);
 
 /*
  * Returns true if it is OK to start a parallel apply worker, false otherwise.
@@ -400,6 +431,7 @@ pa_setup_dsm(ParallelApplyWorkerInfo *winfo)
 	shared = shm_toc_allocate(toc, sizeof(ParallelApplyWorkerShared));
 	SpinLockInit(&shared->mutex);
 
+	shared->xid = InvalidTransactionId;
 	shared->xact_state = PARALLEL_TRANS_UNKNOWN;
 	pg_atomic_init_u32(&(shared->pending_stream_count), 0);
 	shared->last_commit_end = InvalidXLogRecPtr;
@@ -443,6 +475,8 @@ pa_launch_parallel_worker(void)
 	MemoryContext oldcontext;
 	bool		launched;
 	ParallelApplyWorkerInfo *winfo;
+	dsa_handle	pa_dsa_handle;
+	dshash_table_handle pa_dshash_handle;
 	ListCell   *lc;
 
 	/* Try to get an available parallel apply worker from the worker pool. */
@@ -450,10 +484,33 @@ pa_launch_parallel_worker(void)
 	{
 		winfo = (ParallelApplyWorkerInfo *) lfirst(lc);
 
-		if (!winfo->in_use)
+		if (!winfo->stream_txn &&
+			pa_get_xact_state(winfo->shared) == PARALLEL_TRANS_FINISHED)
+		{
+			/*
+			 * Save the local commit LSN of the last transaction applied by
+			 * this worker before reusing it for another transaction. This WAL
+			 * position is crucial for determining the flush position in
+			 * responses to the publisher (see get_flush_position()).
+			 */
+			(void) pa_get_last_commit_end(winfo->shared->xid, false, NULL);
+			return winfo;
+		}
+
+		if (winfo->stream_txn && !winfo->in_use)
 			return winfo;
 	}
 
+	pa_attach_parallelized_txn_hash(&pa_dsa_handle, &pa_dshash_handle);
+
+	/*
+	 * Return if the number of parallel apply workers has reached the maximum
+	 * limit.
+	 */
+	if (list_length(ParallelApplyWorkerPool) ==
+		max_parallel_apply_workers_per_subscription)
+		return NULL;
+
 	/*
 	 * Start a new parallel apply worker.
 	 *
@@ -481,18 +538,32 @@ pa_launch_parallel_worker(void)
 										dsm_segment_handle(winfo->dsm_seg),
 										false);
 
-	if (launched)
-	{
-		ParallelApplyWorkerPool = lappend(ParallelApplyWorkerPool, winfo);
-	}
-	else
+	if (!launched)
 	{
+		MemoryContextSwitchTo(oldcontext);
 		pa_free_worker_info(winfo);
-		winfo = NULL;
+		return NULL;
 	}
 
+	ParallelApplyWorkerPool = lappend(ParallelApplyWorkerPool, winfo);
+
 	MemoryContextSwitchTo(oldcontext);
 
+	/*
+	 * Send all existing remote relation information to the parallel apply
+	 * worker. This allows the parallel worker to initialize the
+	 * LogicalRepRelMapEntry locally before applying remote changes.
+	 */
+	if (logicalrep_get_num_rels())
+	{
+		StringInfoData out;
+
+		initStringInfo(&out);
+
+		write_internal_relation(&out, NULL);
+		pa_send_data(winfo, out.len, out.data);
+	}
+
 	return winfo;
 }
 
@@ -597,7 +668,8 @@ pa_free_worker(ParallelApplyWorkerInfo *winfo)
 {
 	Assert(!am_parallel_apply_worker());
 	Assert(winfo->in_use);
-	Assert(pa_get_xact_state(winfo->shared) == PARALLEL_TRANS_FINISHED);
+	Assert(!winfo->stream_txn ||
+		   pa_get_xact_state(winfo->shared) == PARALLEL_TRANS_FINISHED);
 
 	if (!hash_search(ParallelApplyTxnHash, &winfo->shared->xid, HASH_REMOVE, NULL))
 		elog(ERROR, "hash table corrupted");
@@ -613,9 +685,7 @@ pa_free_worker(ParallelApplyWorkerInfo *winfo)
 	 * been serialized and then letting the parallel apply worker deal with
 	 * the spurious message, we stop the worker.
 	 */
-	if (winfo->serialize_changes ||
-		list_length(ParallelApplyWorkerPool) >
-		(max_parallel_apply_workers_per_subscription / 2))
+	if (winfo->serialize_changes)
 	{
 		logicalrep_pa_worker_stop(winfo);
 		pa_free_worker_info(winfo);
@@ -812,6 +882,38 @@ pa_get_last_commit_end(TransactionId xid, bool delete_entry, bool *skipped_write
 	return entry->local_end;
 }
 
+/*
+ * Wait for the remote transaction associated with the specified remote xid to
+ * complete.
+ */
+static void
+pa_wait_for_transaction(TransactionId wait_for_xid)
+{
+	if (!am_leader_apply_worker())
+		return;
+
+	if (!TransactionIdIsValid(wait_for_xid))
+		return;
+
+	elog(DEBUG1, "plan to wait for remote_xid %u to finish",
+		 wait_for_xid);
+
+	for (;;)
+	{
+		if (pa_transaction_committed(wait_for_xid))
+			break;
+
+		pa_lock_transaction(wait_for_xid, AccessShareLock);
+		pa_unlock_transaction(wait_for_xid, AccessShareLock);
+
+		/* An interrupt may have occurred while we were waiting. */
+		CHECK_FOR_INTERRUPTS();
+	}
+
+	elog(DEBUG1, "finished wait for remote_xid %u to finish",
+		 wait_for_xid);
+}
+
 /*
  * Interrupt handler for main loop of parallel apply worker.
  */
@@ -887,21 +989,34 @@ LogicalParallelApplyLoop(shm_mq_handle *mqh)
 			 * parallel apply workers can only be PqReplMsg_WALData.
 			 */
 			c = pq_getmsgbyte(&s);
-			if (c != PqReplMsg_WALData)
-				elog(ERROR, "unexpected message \"%c\"", c);
-
-			/*
-			 * Ignore statistics fields that have been updated by the leader
-			 * apply worker.
-			 *
-			 * XXX We can avoid sending the statistics fields from the leader
-			 * apply worker but for that, it needs to rebuild the entire
-			 * message by removing these fields which could be more work than
-			 * simply ignoring these fields in the parallel apply worker.
-			 */
-			s.cursor += SIZE_STATS_MESSAGE;
+			if (c == PqReplMsg_WALData)
+			{
+				/*
+				 * Ignore statistics fields that have been updated by the
+				 * leader apply worker.
+				 *
+				 * XXX We can avoid sending the statistics fields from the
+				 * leader apply worker but for that, it needs to rebuild the
+				 * entire message by removing these fields which could be more
+				 * work than simply ignoring these fields in the parallel
+				 * apply worker.
+				 */
+				s.cursor += SIZE_STATS_MESSAGE;
 
-			apply_dispatch(&s);
+				apply_dispatch(&s);
+			}
+			else if (c == PARALLEL_APPLY_INTERNAL_MESSAGE)
+			{
+				apply_dispatch(&s);
+			}
+			else
+			{
+				/*
+				 * The first byte of messages sent from leader apply worker to
+				 * parallel apply workers can only be 'w' or 'i'.
+				 */
+				elog(ERROR, "unexpected message \"%c\"", c);
+			}
 		}
 		else if (shmq_res == SHM_MQ_WOULD_BLOCK)
 		{
@@ -918,6 +1033,9 @@ LogicalParallelApplyLoop(shm_mq_handle *mqh)
 
 				if (rc & WL_LATCH_SET)
 					ResetLatch(MyLatch);
+
+				if (!IsTransactionState())
+					pgstat_report_stat(true);
 			}
 		}
 		else
@@ -955,6 +1073,9 @@ pa_shutdown(int code, Datum arg)
 				   INVALID_PROC_NUMBER);
 
 	dsm_detach((dsm_segment *) DatumGetPointer(arg));
+
+	if (parallel_apply_dsa_area)
+		dsa_detach(parallel_apply_dsa_area);
 }
 
 /*
@@ -1267,7 +1388,6 @@ pa_send_data(ParallelApplyWorkerInfo *winfo, Size nbytes, const void *data)
 	shm_mq_result result;
 	TimestampTz startTime = 0;
 
-	Assert(!IsTransactionState());
 	Assert(!winfo->serialize_changes);
 
 	/*
@@ -1319,6 +1439,67 @@ pa_send_data(ParallelApplyWorkerInfo *winfo, Size nbytes, const void *data)
 	}
 }
 
+/*
+ * Distribute remote relation information to all active parallel apply workers
+ * that require it.
+ */
+void
+pa_distribute_schema_changes_to_workers(LogicalRepRelation *rel)
+{
+	List	   *workers_stopped = NIL;
+	StringInfoData out;
+
+	if (!ParallelApplyWorkerPool)
+		return;
+
+	initStringInfo(&out);
+
+	write_internal_relation(&out, rel);
+
+	foreach_ptr(ParallelApplyWorkerInfo, winfo, ParallelApplyWorkerPool)
+	{
+		/*
+		 * Skip the worker responsible for the current transaction, as the
+		 * relation information has already been sent to it.
+		 */
+		if (winfo == stream_apply_worker)
+			continue;
+
+		/*
+		 * Skip the worker that is in serialize mode, as they will soon stop
+		 * once they finish applying the transaction.
+		 */
+		if (winfo->serialize_changes)
+			continue;
+
+		elog(DEBUG1, "distributing schema changes to pa workers");
+
+		if (pa_send_data(winfo, out.len, out.data))
+			continue;
+
+		elog(DEBUG1, "failed to distribute, will stop that worker instead");
+
+		/*
+		 * Distribution to this worker failed due to a sending timeout. Wait
+		 * for the worker to complete its transaction and then stop it. This
+		 * is consistent with the handling of workers in serialize mode (see
+		 * pa_free_worker() for details).
+		 */
+		pa_wait_for_transaction(winfo->shared->xid);
+
+		pa_get_last_commit_end(winfo->shared->xid, false, NULL);
+
+		logicalrep_pa_worker_stop(winfo);
+
+		workers_stopped = lappend(workers_stopped, winfo);
+	}
+
+	pfree(out.data);
+
+	foreach_ptr(ParallelApplyWorkerInfo, winfo, workers_stopped)
+		pa_free_worker_info(winfo);
+}
+
 /*
  * Switch to PARTIAL_SERIALIZE mode for the current transaction -- this means
  * that the current data and any subsequent data for this transaction will be
@@ -1401,8 +1582,8 @@ pa_wait_for_xact_finish(ParallelApplyWorkerInfo *winfo)
 
 	/*
 	 * Wait for the transaction lock to be released. This is required to
-	 * detect deadlock among leader and parallel apply workers. Refer to the
-	 * comments atop this file.
+	 * detect detect deadlock among leader and parallel apply workers. Refer
+	 * to the comments atop this file.
 	 */
 	pa_lock_transaction(winfo->shared->xid, AccessShareLock);
 	pa_unlock_transaction(winfo->shared->xid, AccessShareLock);
@@ -1479,6 +1660,9 @@ pa_savepoint_name(Oid suboid, TransactionId xid, char *spname, Size szsp)
 void
 pa_start_subtrans(TransactionId current_xid, TransactionId top_xid)
 {
+	if (!TransactionIdIsValid(top_xid))
+		return;
+
 	if (current_xid != top_xid &&
 		!list_member_xid(subxactlist, current_xid))
 	{
@@ -1735,25 +1919,41 @@ pa_decr_and_wait_stream_block(void)
 void
 pa_xact_finish(ParallelApplyWorkerInfo *winfo, XLogRecPtr remote_lsn)
 {
+	XLogRecPtr	local_lsn = InvalidXLogRecPtr;
+	TransactionId pa_remote_xid = winfo->shared->xid;
+
 	Assert(am_leader_apply_worker());
 
 	/*
-	 * Unlock the shared object lock so that parallel apply worker can
-	 * continue to receive and apply changes.
+	 * Unlock the shared object lock taken for streaming transactions so that
+	 * parallel apply worker can continue to receive and apply changes.
 	 */
-	pa_unlock_stream(winfo->shared->xid, AccessExclusiveLock);
+	if (winfo->stream_txn)
+		pa_unlock_stream(winfo->shared->xid, AccessExclusiveLock);
 
 	/*
-	 * Wait for that worker to finish. This is necessary to maintain commit
-	 * order which avoids failures due to transaction dependencies and
-	 * deadlocks.
+	 * Wait for that worker for streaming transaction to finish. This is
+	 * necessary to maintain commit order which avoids failures due to
+	 * transaction dependencies and deadlocks.
+	 *
+	 * For non-streaming transaction but in partial seralize mode, wait for
+	 * stop as well as the worker is anyway cannot be reused anymore (see
+	 * pa_free_worker() for details).
 	 */
-	pa_wait_for_xact_finish(winfo);
+	if (winfo->serialize_changes || winfo->stream_txn)
+	{
+		pa_wait_for_xact_finish(winfo);
+
+		local_lsn = winfo->shared->last_commit_end;
+		pa_remote_xid = InvalidTransactionId;
+
+		pa_free_worker(winfo);
+	}
 
 	if (XLogRecPtrIsValid(remote_lsn))
-		store_flush_position(remote_lsn, winfo->shared->last_commit_end);
+		store_flush_position(remote_lsn, local_lsn, pa_remote_xid);
 
-	pa_free_worker(winfo);
+	pa_set_stream_apply_worker(NULL);
 }
 
 bool
@@ -1852,6 +2052,22 @@ pa_record_dependency_on_transactions(List *depends_on_xids)
 	}
 }
 
+/*
+ * Mark the transaction state as finished and remove the shared hash entry.
+ */
+void
+pa_commit_transaction(void)
+{
+	TransactionId xid = MyParallelShared->xid;
+
+	SpinLockAcquire(&MyParallelShared->mutex);
+	MyParallelShared->xact_state = PARALLEL_TRANS_FINISHED;
+	SpinLockRelease(&MyParallelShared->mutex);
+
+	dshash_delete_key(parallelized_txns, &xid);
+	elog(DEBUG1, "depended xid %u committed", xid);
+}
+
 /*
  * Wait for the given transaction to finish.
  */
@@ -1860,6 +2076,13 @@ pa_wait_for_depended_transaction(TransactionId xid)
 {
 	elog(DEBUG1, "wait for depended xid %u", xid);
 
+	/*
+	 * Quick exit if parallelized_txns has not been initialized yet. This can
+	 * happen when this function is called by the leader worker.
+	 */
+	if (!parallelized_txns)
+		return;
+
 	for (;;)
 	{
 		ParallelizedTxnEntry *txn_entry;
@@ -1880,3 +2103,45 @@ pa_wait_for_depended_transaction(TransactionId xid)
 
 	elog(DEBUG1, "finish waiting for depended xid %u", xid);
 }
+
+/*
+ * Write internal relation description to the output stream.
+ */
+static void
+write_internal_relation(StringInfo s, LogicalRepRelation *rel)
+{
+	pq_sendbyte(s, PARALLEL_APPLY_INTERNAL_MESSAGE);
+	pq_sendbyte(s, LOGICAL_REP_MSG_INTERNAL_RELATION);
+
+	if (rel)
+	{
+		pq_sendint(s, 1, 4);
+		logicalrep_write_internal_rel(s, rel);
+	}
+	else
+	{
+		pq_sendint(s, logicalrep_get_num_rels(), 4);
+		logicalrep_write_all_rels(s);
+	}
+}
+
+/*
+ * Register a transaction to the shared hash table.
+ *
+ * This function is intended to be called during the commit phase of
+ * non-streamed transactions. Other parallel workers would wait,
+ * removing the added entry.
+ */
+void
+pa_add_parallelized_transaction(TransactionId xid)
+{
+	bool		found;
+	ParallelizedTxnEntry *txn_entry;
+
+	Assert(parallelized_txns);
+	Assert(TransactionIdIsValid(xid));
+
+	txn_entry = dshash_find_or_insert(parallelized_txns, &xid, &found);
+
+	dshash_release_lock(parallelized_txns, txn_entry);
+}
diff --git a/src/backend/replication/logical/proto.c b/src/backend/replication/logical/proto.c
index 66f930c8b06..d231bfb48a3 100644
--- a/src/backend/replication/logical/proto.c
+++ b/src/backend/replication/logical/proto.c
@@ -691,6 +691,44 @@ logicalrep_write_rel(StringInfo out, TransactionId xid, Relation rel,
 	logicalrep_write_attrs(out, rel, columns, include_gencols_type);
 }
 
+/*
+ * Write internal relation description to the output stream.
+ */
+void
+logicalrep_write_internal_rel(StringInfo out, LogicalRepRelation *rel)
+{
+	pq_sendint32(out, rel->remoteid);
+
+	/* Write relation name */
+	pq_sendstring(out, rel->nspname);
+	pq_sendstring(out, rel->relname);
+
+	/* Write the replica identity. */
+	pq_sendbyte(out, rel->replident);
+
+	/* Write attribute description */
+	pq_sendint16(out, rel->natts);
+
+	for (int i = 0; i < rel->natts; i++)
+	{
+		uint8		flags = 0;
+
+		if (bms_is_member(i, rel->attkeys))
+			flags |= LOGICALREP_IS_REPLICA_IDENTITY;
+
+		pq_sendbyte(out, flags);
+
+		/* attribute name */
+		pq_sendstring(out, rel->attnames[i]);
+
+		/* attribute type id */
+		pq_sendint32(out, rel->atttyps[i]);
+
+		/* ignore attribute mode for now */
+		pq_sendint32(out, 0);
+	}
+}
+
 /*
  * Read the relation info from stream and return as LogicalRepRelation.
  */
diff --git a/src/backend/replication/logical/relation.c b/src/backend/replication/logical/relation.c
index ab6313eb1bc..e1ce183dfd3 100644
--- a/src/backend/replication/logical/relation.c
+++ b/src/backend/replication/logical/relation.c
@@ -961,6 +961,37 @@ FindLogicalRepLocalIndex(Relation localrel, LogicalRepRelation *remoterel,
 	return InvalidOid;
 }
 
+/*
+ * Get the number of entries in the LogicalRepRelMap.
+ */
+int
+logicalrep_get_num_rels(void)
+{
+	if (LogicalRepRelMap == NULL)
+		return 0;
+
+	return hash_get_num_entries(LogicalRepRelMap);
+}
+
+/*
+ * Write all the remote relation information from the LogicalRepRelMapEntry to
+ * the output stream.
+ */
+void
+logicalrep_write_all_rels(StringInfo out)
+{
+	LogicalRepRelMapEntry *entry;
+	HASH_SEQ_STATUS status;
+
+	if (LogicalRepRelMap == NULL)
+		return;
+
+	hash_seq_init(&status, LogicalRepRelMap);
+
+	while ((entry = (LogicalRepRelMapEntry *) hash_seq_search(&status)) != NULL)
+		logicalrep_write_internal_rel(out, &entry->remoterel);
+}
+
 /*
  * Get the LogicalRepRelMapEntry corresponding to the given relid without
  * opening the local relation.
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index c2967caf1e5..d1ba801f96f 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -286,6 +286,7 @@
 #include "tcop/tcopprot.h"
 #include "utils/acl.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/lsyscache.h"
 #include "utils/memutils.h"
@@ -484,6 +485,8 @@ static List *on_commit_wakeup_workers_subids = NIL;
 
 bool		in_remote_transaction = false;
 static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+static TransactionId remote_xid = InvalidTransactionId;
+static TransactionId last_remote_xid = InvalidTransactionId;
 
 /* fields valid only when processing streamed transaction */
 static bool in_streamed_transaction = false;
@@ -602,11 +605,7 @@ static inline void cleanup_subxact_info(void);
 /*
  * Serialize and deserialize changes for a toplevel transaction.
  */
-static void stream_open_file(Oid subid, TransactionId xid,
-							 bool first_segment);
 static void stream_write_change(char action, StringInfo s);
-static void stream_open_and_write_change(TransactionId xid, char action, StringInfo s);
-static void stream_close_file(void);
 
 static void send_feedback(XLogRecPtr recvpos, bool force, bool requestReply);
 
@@ -676,6 +675,8 @@ static void on_exit_clear_xact_state(int code, Datum arg);
 static bool send_internal_dependencies(ParallelApplyWorkerInfo *winfo,
 									   StringInfo s);
 
+static bool build_dependency_with_last_committed_txn(ParallelApplyWorkerInfo *winfo);
+
 /*
  * Compute the hash value for entries in the replica_identity_table.
  */
@@ -1406,7 +1407,11 @@ handle_streamed_transaction(LogicalRepMsgType action, StringInfo s)
 	TransApplyAction apply_action;
 	StringInfoData original_msg;
 
-	apply_action = get_transaction_apply_action(stream_xid, &winfo);
+	Assert(!in_streamed_transaction || TransactionIdIsValid(stream_xid));
+
+	apply_action = get_transaction_apply_action(in_streamed_transaction
+												? stream_xid : remote_xid,
+												&winfo);
 
 	/* not in streaming mode */
 	if (apply_action == TRANS_LEADER_APPLY)
@@ -1415,8 +1420,6 @@ handle_streamed_transaction(LogicalRepMsgType action, StringInfo s)
 		return false;
 	}
 
-	Assert(TransactionIdIsValid(stream_xid));
-
 	/*
 	 * The parallel apply worker needs the xid in this message to decide
 	 * whether to define a savepoint, so save the original message that has
@@ -1427,15 +1430,28 @@ handle_streamed_transaction(LogicalRepMsgType action, StringInfo s)
 
 	/*
 	 * We should have received XID of the subxact as the first part of the
-	 * message, so extract it.
+	 * message in streaming transactions, so extract it.
 	 */
-	current_xid = pq_getmsgint(s, 4);
+	if (in_streamed_transaction)
+		current_xid = pq_getmsgint(s, 4);
+	else
+		current_xid = remote_xid;
 
 	if (!TransactionIdIsValid(current_xid))
 		ereport(ERROR,
 				(errcode(ERRCODE_PROTOCOL_VIOLATION),
 				 errmsg_internal("invalid transaction ID in streamed replication transaction")));
 
+	handle_dependency_on_change(action, s, current_xid, winfo);
+
+	/*
+	 * Re-fetch the latest apply action as it might have been changed during
+	 * dependency check.
+	 */
+	apply_action = get_transaction_apply_action(in_streamed_transaction
+												? stream_xid : remote_xid,
+												&winfo);
+
 	switch (apply_action)
 	{
 		case TRANS_LEADER_SERIALIZE:
@@ -1839,17 +1855,71 @@ static void
 apply_handle_begin(StringInfo s)
 {
 	LogicalRepBeginData begin_data;
+	ParallelApplyWorkerInfo *winfo;
+	TransApplyAction apply_action;
+
+	/* Save the message before it is consumed. */
+	StringInfoData original_msg = *s;
 
 	/* There must not be an active streaming transaction. */
 	Assert(!TransactionIdIsValid(stream_xid));
 
 	logicalrep_read_begin(s, &begin_data);
-	set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
+
+	remote_xid = begin_data.xid;
+
+	set_apply_error_context_xact(remote_xid, begin_data.final_lsn);
 
 	remote_final_lsn = begin_data.final_lsn;
 
 	maybe_start_skipping_changes(begin_data.final_lsn);
 
+	pa_allocate_worker(remote_xid, false);
+
+	apply_action = get_transaction_apply_action(remote_xid, &winfo);
+
+	elog(DEBUG1, "new remote_xid %u", remote_xid);
+	switch (apply_action)
+	{
+		case TRANS_LEADER_APPLY:
+			break;
+
+		case TRANS_LEADER_SEND_TO_PARALLEL:
+			Assert(winfo);
+
+			if (pa_send_data(winfo, s->len, s->data))
+			{
+				pa_set_stream_apply_worker(winfo);
+				break;
+			}
+
+			/*
+			 * Switch to serialize mode when we are not able to send the
+			 * change to parallel apply worker.
+			 */
+			pa_switch_to_partial_serialize(winfo, true);
+
+/* fall through */
+		case TRANS_LEADER_PARTIAL_SERIALIZE:
+			Assert(winfo);
+
+			stream_write_change(LOGICAL_REP_MSG_BEGIN, &original_msg);
+
+			/* Cache the parallel apply worker for this transaction. */
+			pa_set_stream_apply_worker(winfo);
+			break;
+
+		case TRANS_PARALLEL_APPLY:
+			/* Hold the lock until the end of the transaction. */
+			pa_lock_transaction(MyParallelShared->xid, AccessExclusiveLock);
+			pa_set_xact_state(MyParallelShared, PARALLEL_TRANS_STARTED);
+			break;
+
+		default:
+			elog(ERROR, "unexpected apply action: %d", (int) apply_action);
+			break;
+	}
+
 	in_remote_transaction = true;
 
 	pgstat_report_activity(STATE_RUNNING, NULL);
@@ -1882,6 +1952,37 @@ send_internal_dependencies(ParallelApplyWorkerInfo *winfo, StringInfo s)
 	return false;
 }
 
+/*
+ * Make a dependency between this and the lastly committed transaction.
+ *
+ * This function ensures that the commit ordering handled by parallel apply
+ * workers is preserved. Returns false if we switched to the serialize mode to
+ * send the massage, true otherwise.
+ */
+static bool
+build_dependency_with_last_committed_txn(ParallelApplyWorkerInfo *winfo)
+{
+	StringInfoData dependency_msg;
+	bool		ret;
+
+	/* Skip if transactions have not been applied yet */
+	if (!TransactionIdIsValid(last_remote_xid))
+		return true;
+
+	/* Build the dependency message used to send to parallel apply worker */
+	initStringInfo(&dependency_msg);
+
+	pq_sendbyte(&dependency_msg, PARALLEL_APPLY_INTERNAL_MESSAGE);
+	pq_sendbyte(&dependency_msg, LOGICAL_REP_MSG_INTERNAL_DEPENDENCY);
+	pq_sendint32(&dependency_msg, 1);
+	pq_sendint32(&dependency_msg, last_remote_xid);
+
+	ret = send_internal_dependencies(winfo, &dependency_msg);
+
+	pfree(dependency_msg.data);
+	return ret;
+}
+
 /*
  * Handle COMMIT message.
  *
@@ -1891,6 +1992,11 @@ static void
 apply_handle_commit(StringInfo s)
 {
 	LogicalRepCommitData commit_data;
+	ParallelApplyWorkerInfo *winfo;
+	TransApplyAction apply_action;
+
+	/* Save the message before it is consumed. */
+	StringInfoData original_msg = *s;
 
 	logicalrep_read_commit(s, &commit_data);
 
@@ -1901,7 +2007,98 @@ apply_handle_commit(StringInfo s)
 								 LSN_FORMAT_ARGS(commit_data.commit_lsn),
 								 LSN_FORMAT_ARGS(remote_final_lsn))));
 
-	apply_handle_commit_internal(&commit_data);
+	apply_action = get_transaction_apply_action(remote_xid, &winfo);
+
+	switch (apply_action)
+	{
+		case TRANS_LEADER_APPLY:
+
+			/*
+			 * Apart from parallelized transactions, we do not have to
+			 * register this transaction to parallelized_txns. The commit
+			 * ordering is always preserved.
+			 */
+
+			/* Wait until the last transaction finishes */
+			if (TransactionIdIsValid(last_remote_xid))
+				pa_wait_for_depended_transaction(last_remote_xid);
+
+			apply_handle_commit_internal(&commit_data);
+
+			break;
+
+		case TRANS_LEADER_SEND_TO_PARALLEL:
+			Assert(winfo);
+
+			/*
+			 * Mark this transaction as parallelized. This ensures that
+			 * upcoming transactions wait until this transaction is committed.
+			 */
+			pa_add_parallelized_transaction(remote_xid);
+
+			/*
+			 * Build a dependency between this transaction and the lastly
+			 * committed transaction to preserve the commit order. Then try to
+			 * send a COMMIT message if succeeded.
+			 */
+			if (build_dependency_with_last_committed_txn(winfo) &&
+				pa_send_data(winfo, s->len, s->data))
+			{
+				/* Finish processing the transaction. */
+				pa_xact_finish(winfo, commit_data.end_lsn);
+				break;
+			}
+
+			/*
+			 * Switch to serialize mode when we are not able to send the
+			 * change to parallel apply worker.
+			 */
+			pa_switch_to_partial_serialize(winfo, true);
+/* fall through */
+		case TRANS_LEADER_PARTIAL_SERIALIZE:
+			Assert(winfo);
+
+			stream_open_and_write_change(remote_xid, LOGICAL_REP_MSG_COMMIT,
+										 &original_msg);
+
+			pa_set_fileset_state(winfo->shared, FS_SERIALIZE_DONE);
+
+			/* Finish processing the transaction. */
+			pa_xact_finish(winfo, commit_data.end_lsn);
+			break;
+
+		case TRANS_PARALLEL_APPLY:
+
+			/*
+			 * If the parallel apply worker is applying spooled messages then
+			 * close the file before committing.
+			 */
+			if (stream_fd)
+				stream_close_file();
+
+			INJECTION_POINT("parallel-worker-before-commit", NULL);
+
+			apply_handle_commit_internal(&commit_data);
+
+			MyParallelShared->last_commit_end = XactLastCommitEnd;
+
+			pa_commit_transaction();
+
+			pa_unlock_transaction(remote_xid, AccessExclusiveLock);
+			break;
+
+		default:
+			elog(ERROR, "unexpected apply action: %d", (int) apply_action);
+			break;
+	}
+
+	/* Cache the remote_xid */
+	last_remote_xid = remote_xid;
+
+	remote_xid = InvalidTransactionId;
+	in_remote_transaction = false;
+
+	elog(DEBUG1, "reset remote_xid %u", remote_xid);
 
 	/*
 	 * Process any tables that are being synchronized in parallel, as well as
@@ -2024,7 +2221,8 @@ apply_handle_prepare(StringInfo s)
 	 * XactLastCommitEnd, and adding it for this purpose doesn't seems worth
 	 * it.
 	 */
-	store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr);
+	store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr,
+						 InvalidTransactionId);
 
 	in_remote_transaction = false;
 
@@ -2084,7 +2282,8 @@ apply_handle_commit_prepared(StringInfo s)
 	CommitTransactionCommand();
 	pgstat_report_stat(false);
 
-	store_flush_position(prepare_data.end_lsn, XactLastCommitEnd);
+	store_flush_position(prepare_data.end_lsn, XactLastCommitEnd,
+						 InvalidTransactionId);
 	in_remote_transaction = false;
 
 	/*
@@ -2153,7 +2352,8 @@ apply_handle_rollback_prepared(StringInfo s)
 	 * transaction because we always flush the WAL record for it. See
 	 * apply_handle_prepare.
 	 */
-	store_flush_position(rollback_data.rollback_end_lsn, InvalidXLogRecPtr);
+	store_flush_position(rollback_data.rollback_end_lsn, InvalidXLogRecPtr,
+						 InvalidTransactionId);
 	in_remote_transaction = false;
 
 	/*
@@ -2215,7 +2415,8 @@ apply_handle_stream_prepare(StringInfo s)
 			 * It is okay not to set the local_end LSN for the prepare because
 			 * we always flush the prepare record. See apply_handle_prepare.
 			 */
-			store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr);
+			store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr,
+								 InvalidTransactionId);
 
 			in_remote_transaction = false;
 
@@ -2467,6 +2668,11 @@ apply_handle_stream_start(StringInfo s)
 		case TRANS_LEADER_PARTIAL_SERIALIZE:
 			Assert(winfo);
 
+			/*
+			 * TODO, the pa worker could start to wait too soon when
+			 * processing some old stream start
+			 */
+
 			/*
 			 * Open the spool file unless it was already opened when switching
 			 * to serialize mode. The transaction started in
@@ -3194,7 +3400,8 @@ apply_handle_commit_internal(LogicalRepCommitData *commit_data)
 
 		pgstat_report_stat(false);
 
-		store_flush_position(commit_data->end_lsn, XactLastCommitEnd);
+		store_flush_position(commit_data->end_lsn, XactLastCommitEnd,
+							 InvalidTransactionId);
 	}
 	else
 	{
@@ -3227,6 +3434,9 @@ apply_handle_relation(StringInfo s)
 
 	/* Also reset all entries in the partition map that refer to remoterel. */
 	logicalrep_partmap_reset_relmap(rel);
+
+	if (am_leader_apply_worker())
+		pa_distribute_schema_changes_to_workers(rel);
 }
 
 /*
@@ -4001,6 +4211,8 @@ FindDeletedTupleInLocalRel(Relation localrel, Oid localidxoid,
 
 /*
  * This handles insert, update, delete on a partitioned table.
+ *
+ * TODO, support parallel apply.
  */
 static void
 apply_handle_tuple_routing(ApplyExecutionData *edata,
@@ -4551,6 +4763,10 @@ apply_dispatch(StringInfo s)
  * check which entries on it are already locally flushed. Those we can report
  * as having been flushed.
  *
+ * For non-streaming transactions managed by a parallel apply worker, we will
+ * get the local commit end from the shared parallel apply worker info once the
+ * transaction has been committed by the worker.
+ *
  * The have_pending_txes is true if there are outstanding transactions that
  * need to be flushed.
  */
@@ -4560,6 +4776,7 @@ get_flush_position(XLogRecPtr *write, XLogRecPtr *flush,
 {
 	dlist_mutable_iter iter;
 	XLogRecPtr	local_flush = GetFlushRecPtr(NULL);
+	List	   *committed_pa_xid = NIL;
 
 	*write = InvalidXLogRecPtr;
 	*flush = InvalidXLogRecPtr;
@@ -4569,6 +4786,36 @@ get_flush_position(XLogRecPtr *write, XLogRecPtr *flush,
 		FlushPosition *pos =
 			dlist_container(FlushPosition, node, iter.cur);
 
+		if (TransactionIdIsValid(pos->pa_remote_xid) &&
+			XLogRecPtrIsInvalid(pos->local_end))
+		{
+			bool		skipped_write;
+
+			pos->local_end = pa_get_last_commit_end(pos->pa_remote_xid, true,
+													&skipped_write);
+
+			elog(DEBUG1,
+				 "got commit end from parallel apply worker, "
+				 "txn: %u, remote_end %X/%X, local_end %X/%X",
+				 pos->pa_remote_xid, LSN_FORMAT_ARGS(pos->remote_end),
+				 LSN_FORMAT_ARGS(pos->local_end));
+
+			/*
+			 * Break the loop if the worker has not finished applying the
+			 * transaction. There's no need to check subsequent transactions,
+			 * as they must commit after the current transaction being
+			 * examined and thus won't have their commit end available yet.
+			 */
+			if (!skipped_write && XLogRecPtrIsInvalid(pos->local_end))
+				break;
+
+			committed_pa_xid = lappend_xid(committed_pa_xid, pos->pa_remote_xid);
+		}
+
+		/*
+		 * Worker has finished applying or the transaction was applied in the
+		 * leader apply worker
+		 */
 		*write = pos->remote_end;
 
 		if (pos->local_end <= local_flush)
@@ -4577,29 +4824,19 @@ get_flush_position(XLogRecPtr *write, XLogRecPtr *flush,
 			dlist_delete(iter.cur);
 			pfree(pos);
 		}
-		else
-		{
-			/*
-			 * Don't want to uselessly iterate over the rest of the list which
-			 * could potentially be long. Instead get the last element and
-			 * grab the write position from there.
-			 */
-			pos = dlist_tail_element(FlushPosition, node,
-									 &lsn_mapping);
-			*write = pos->remote_end;
-			*have_pending_txes = true;
-			return;
-		}
 	}
 
 	*have_pending_txes = !dlist_is_empty(&lsn_mapping);
+
+	cleanup_replica_identity_table(committed_pa_xid);
 }
 
 /*
  * Store current remote/local lsn pair in the tracking list.
  */
 void
-store_flush_position(XLogRecPtr remote_lsn, XLogRecPtr local_lsn)
+store_flush_position(XLogRecPtr remote_lsn, XLogRecPtr local_lsn,
+					 TransactionId remote_xid)
 {
 	FlushPosition *flushpos;
 
@@ -4617,6 +4854,7 @@ store_flush_position(XLogRecPtr remote_lsn, XLogRecPtr local_lsn)
 	flushpos = palloc_object(FlushPosition);
 	flushpos->local_end = local_lsn;
 	flushpos->remote_end = remote_lsn;
+	flushpos->pa_remote_xid = remote_xid;
 
 	dlist_push_tail(&lsn_mapping, &flushpos->node);
 	MemoryContextSwitchTo(ApplyMessageContext);
@@ -6064,7 +6302,7 @@ stream_cleanup_files(Oid subid, TransactionId xid)
  * changes for this transaction, create the buffile, otherwise open the
  * previously created file.
  */
-static void
+void
 stream_open_file(Oid subid, TransactionId xid, bool first_segment)
 {
 	char		path[MAXPGPATH];
@@ -6109,7 +6347,7 @@ stream_open_file(Oid subid, TransactionId xid, bool first_segment)
  * stream_close_file
  *	  Close the currently open file with streamed changes.
  */
-static void
+void
 stream_close_file(void)
 {
 	Assert(stream_fd != NULL);
@@ -6157,7 +6395,7 @@ stream_write_change(char action, StringInfo s)
  * target file if not already before writing the message and close the file at
  * the end.
  */
-static void
+void
 stream_open_and_write_change(TransactionId xid, char action, StringInfo s)
 {
 	Assert(!in_streamed_transaction);
diff --git a/src/include/replication/logicalproto.h b/src/include/replication/logicalproto.h
index 9042470f500..c0c0b385d40 100644
--- a/src/include/replication/logicalproto.h
+++ b/src/include/replication/logicalproto.h
@@ -253,6 +253,8 @@ extern void logicalrep_write_message(StringInfo out, TransactionId xid, XLogRecP
 extern void logicalrep_write_rel(StringInfo out, TransactionId xid,
 								 Relation rel, Bitmapset *columns,
 								 PublishGencolsType include_gencols_type);
+extern void logicalrep_write_internal_rel(StringInfo out,
+										  LogicalRepRelation *rel);
 extern LogicalRepRelation *logicalrep_read_rel(StringInfo in);
 extern void logicalrep_write_typ(StringInfo out, TransactionId xid,
 								 Oid typoid);
diff --git a/src/include/replication/logicalrelation.h b/src/include/replication/logicalrelation.h
index f9aa9e59a6d..27e365f84c2 100644
--- a/src/include/replication/logicalrelation.h
+++ b/src/include/replication/logicalrelation.h
@@ -52,6 +52,8 @@ extern void logicalrep_rel_close(LogicalRepRelMapEntry *rel,
 								 LOCKMODE lockmode);
 extern bool IsIndexUsableForReplicaIdentityFull(Relation idxrel, AttrMap *attrmap);
 extern Oid	GetRelationIdentityOrPK(Relation rel);
+extern int	logicalrep_get_num_rels(void);
+extern void logicalrep_write_all_rels(StringInfo out);
 extern LogicalRepRelMapEntry *logicalrep_get_relentry(LogicalRepRelId remoteid);
 
 #endif							/* LOGICALRELATION_H */
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index 1dbec625c81..d5bf4598980 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -314,6 +314,10 @@ extern void apply_dispatch(StringInfo s);
 extern void maybe_reread_subscription(void);
 
 extern void stream_cleanup_files(Oid subid, TransactionId xid);
+extern void stream_open_file(Oid subid, TransactionId xid, bool first_segment);
+extern void stream_close_file(void);
+extern void stream_open_and_write_change(TransactionId xid, char action,
+										 StringInfo s);
 
 extern void set_stream_options(WalRcvStreamOptions *options,
 							   char *slotname,
@@ -327,7 +331,8 @@ extern void SetupApplyOrSyncWorker(int worker_slot);
 
 extern void DisableSubscriptionAndExit(void);
 
-extern void store_flush_position(XLogRecPtr remote_lsn, XLogRecPtr local_lsn);
+extern void store_flush_position(XLogRecPtr remote_lsn, XLogRecPtr local_lsn,
+								 TransactionId remote_xid);
 
 /* Function for apply error callback */
 extern void apply_error_callback(void *arg);
@@ -342,6 +347,7 @@ extern void pa_detach_all_error_mq(void);
 
 extern bool pa_send_data(ParallelApplyWorkerInfo *winfo, Size nbytes,
 						 const void *data);
+extern void pa_distribute_schema_changes_to_workers(LogicalRepRelation *rel);
 extern void pa_switch_to_partial_serialize(ParallelApplyWorkerInfo *winfo,
 										   bool stream_locked);
 
@@ -368,8 +374,9 @@ extern void pa_xact_finish(ParallelApplyWorkerInfo *winfo,
 						   XLogRecPtr remote_lsn);
 extern bool pa_transaction_committed(TransactionId xid);
 extern void pa_record_dependency_on_transactions(List *depends_on_xids);
-
+extern void pa_commit_transaction(void);
 extern void pa_wait_for_depended_transaction(TransactionId xid);
+extern void pa_add_parallelized_transaction(TransactionId xid);
 
 #define isParallelApplyWorker(worker) ((worker)->in_use && \
 									   (worker)->type == WORKERTYPE_PARALLEL_APPLY)
diff --git a/src/test/subscription/meson.build b/src/test/subscription/meson.build
index a4c7dbaff59..6251eec2030 100644
--- a/src/test/subscription/meson.build
+++ b/src/test/subscription/meson.build
@@ -46,6 +46,7 @@ tests += {
       't/034_temporal.pl',
       't/035_conflicts.pl',
       't/036_sequences.pl',
+      't/050_parallel_apply.pl',
       't/100_bugs.pl',
     ],
   },
diff --git a/src/test/subscription/t/001_rep_changes.pl b/src/test/subscription/t/001_rep_changes.pl
index d7e62e4d488..05d02ef6407 100644
--- a/src/test/subscription/t/001_rep_changes.pl
+++ b/src/test/subscription/t/001_rep_changes.pl
@@ -16,6 +16,8 @@ $node_publisher->start;
 # Create subscriber node
 my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
 $node_subscriber->init;
+$node_subscriber->append_conf('postgresql.conf',
+	"max_logical_replication_workers = 10");
 $node_subscriber->start;
 
 # Create some preexisting content on publisher
diff --git a/src/test/subscription/t/010_truncate.pl b/src/test/subscription/t/010_truncate.pl
index 945505d0239..e15a6bb2a03 100644
--- a/src/test/subscription/t/010_truncate.pl
+++ b/src/test/subscription/t/010_truncate.pl
@@ -17,7 +17,7 @@ $node_publisher->start;
 my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
 $node_subscriber->init;
 $node_subscriber->append_conf('postgresql.conf',
-	qq(max_logical_replication_workers = 6));
+	qq(max_logical_replication_workers = 7));
 $node_subscriber->start;
 
 my $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
diff --git a/src/test/subscription/t/015_stream.pl b/src/test/subscription/t/015_stream.pl
index ac96bc3f009..705bc83f5ea 100644
--- a/src/test/subscription/t/015_stream.pl
+++ b/src/test/subscription/t/015_stream.pl
@@ -232,6 +232,12 @@ $node_subscriber->wait_for_log(
 
 $node_publisher->safe_psql('postgres', "INSERT INTO test_tab_2 values(1)");
 
+# FIXME: Currently, non-streaming transactions are applied in parallel by
+# default. So, the first transaction is handled by a parallel apply worker. To
+# trigger the deadlock, initiate an more transaction to be applied by the
+# leader.
+$node_publisher->safe_psql('postgres', "INSERT INTO test_tab_2 values(1)");
+
 $h->query_safe('COMMIT');
 $h->quit;
 
@@ -247,7 +253,7 @@ $node_publisher->wait_for_catchup($appname);
 
 $result =
   $node_subscriber->safe_psql('postgres', "SELECT count(*) FROM test_tab_2");
-is($result, qq(5001), 'data replicated to subscriber after dropping index');
+is($result, qq(5002), 'data replicated to subscriber after dropping index');
 
 # Clean up test data from the environment.
 $node_publisher->safe_psql('postgres', "TRUNCATE TABLE test_tab_2");
diff --git a/src/test/subscription/t/026_stats.pl b/src/test/subscription/t/026_stats.pl
index 5d457060a02..911005bde20 100644
--- a/src/test/subscription/t/026_stats.pl
+++ b/src/test/subscription/t/026_stats.pl
@@ -16,6 +16,7 @@ $node_publisher->start;
 # Create subscriber node.
 my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
 $node_subscriber->init;
+$node_subscriber->append_conf('postgresql.conf', "max_logical_replication_workers = 10");
 $node_subscriber->start;
 
 
diff --git a/src/test/subscription/t/027_nosuperuser.pl b/src/test/subscription/t/027_nosuperuser.pl
index 322f5b4cc6a..fdfc44ac729 100644
--- a/src/test/subscription/t/027_nosuperuser.pl
+++ b/src/test/subscription/t/027_nosuperuser.pl
@@ -86,6 +86,7 @@ $node_publisher = PostgreSQL::Test::Cluster->new('publisher');
 $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
 $node_publisher->init(allows_streaming => 'logical');
 $node_subscriber->init;
+$node_subscriber->append_conf('postgresql.conf', "max_logical_replication_workers = 10");
 $node_publisher->start;
 $node_subscriber->start;
 $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
diff --git a/src/test/subscription/t/050_parallel_apply.pl b/src/test/subscription/t/050_parallel_apply.pl
new file mode 100644
index 00000000000..69cf48cb7ac
--- /dev/null
+++ b/src/test/subscription/t/050_parallel_apply.pl
@@ -0,0 +1,130 @@
+
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+# This tests that dependency tracking between transactions can work well
+
+use strict;
+use warnings;
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+if ($ENV{enable_injection_points} ne 'yes')
+{
+	plan skip_all => 'Injection points not supported by this build';
+}
+
+# Initialize publisher node
+my $node_publisher = PostgreSQL::Test::Cluster->new('publisher');
+$node_publisher->init(allows_streaming => 'logical');
+$node_publisher->start;
+
+# Insert initial data
+$node_publisher->safe_psql('postgres',
+    "CREATE TABLE regress_tab (id int PRIMARY KEY, value text);");
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(1, 10), 'test');");
+
+# Create a publication
+$node_publisher->safe_psql('postgres',
+    "CREATE PUBLICATION regress_pub FOR ALL TABLES;");
+
+# Initialize subscriber node
+my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
+$node_subscriber->init;
+$node_subscriber->append_conf('postgresql.conf', "log_min_messages = debug1");
+$node_subscriber->append_conf('postgresql.conf',
+	"max_logical_replication_workers = 10");
+$node_subscriber->start;
+
+# Check if the extension injection_points is available, as it may be
+# possible that this script is run with installcheck, where the module
+# would not be installed by default.
+if (!$node_subscriber->check_extension('injection_points'))
+{
+	plan skip_all => 'Extension injection_points not installed';
+}
+
+$node_subscriber->safe_psql('postgres', 'CREATE EXTENSION injection_points;');
+
+# Create a subscription
+my $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
+
+$node_subscriber->safe_psql('postgres',
+    "CREATE TABLE regress_tab (id int PRIMARY KEY, value text);");
+$node_subscriber->safe_psql('postgres',
+    "CREATE SUBSCRIPTION regress_sub CONNECTION '$publisher_connstr' PUBLICATION regress_pub;");
+
+# Wait for initial table sync to finish
+$node_subscriber->wait_for_subscription_sync($node_publisher, 'regress_sub');
+
+# Insert tuples on publisher
+#
+# XXX This may not enough to launch a parallel apply worker, because
+# table_states_not_ready is not discarded yet.
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(11, 20), 'test');");
+$node_publisher->wait_for_catchup('regress_sub');
+
+# Insert tuples again
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(21, 30), 'test');");
+$node_publisher->wait_for_catchup('regress_sub');
+
+# Verify the parallel apply worker is launched
+my $result = $node_subscriber->safe_psql('postgres',
+    "SELECT count(1) FROM pg_stat_activity WHERE backend_type = 'logical replication parallel worker'");
+is($result, '1', "parallel apply worker is laucnhed by a non-streamed transaction");
+
+# Attach an injection_point. Parallel workers would wait before the commit
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+# Insert tuples on publisher
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(31, 40), 'test');");
+
+# Wait until the parallel worker enters the injection point.
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+my $offset = -s $node_subscriber->logfile;
+
+# Insert tuples on publisher again. This transaction is independent from the
+# previous one, but the parallel worker would wait till it finishes
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(41, 50), 'test');");
+
+# Verify the parallel worker waits for the transaction
+my $str = $node_subscriber->wait_for_log(qr/wait for depended xid ([1-9][0-9]+)/, $offset);
+my $xid = $str =~ /wait for depended xid ([1-9][0-9]+)/;
+
+# Update tuples which have not been applied yet on subscriber because the
+# parallel worker stops at the injection point. Newly assigned worker also
+# waits for the same transactions as above.
+$node_publisher->safe_psql('postgres',
+    "UPDATE regress_tab SET value = 'updated' WHERE id BETWEEN 31 AND 35;");
+
+# Verify the parallel worker waits for the same transaction
+$node_subscriber->wait_for_log(qr/wait for depended xid $xid/, $offset);
+
+# Wakeup the parallel worker. We detach first no to stop other parallel workers
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+# Verify the parallel worker wakes up
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab");
+is ($result, 50, 'inserts are replicated to subscriber');
+
+$result =
+  $node_subscriber->safe_psql('postgres',
+    "SELECT count(1) FROM regress_tab WHERE value = 'updated'");
+is ($result, 5, 'updates are also replicated to subscriber');
+
+done_testing();
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 05936d61a54..811a23ac03c 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2094,6 +2094,7 @@ ParallelHashGrowth
 ParallelHashJoinBatch
 ParallelHashJoinBatchAccessor
 ParallelHashJoinState
+ParallelizedTxnEntry
 ParallelIndexScanDesc
 ParallelizedTxnEntry
 ParallelSlot
@@ -4098,6 +4099,7 @@ rendezvousHashEntry
 rep
 replace_rte_variables_callback
 replace_rte_variables_context
+replica_identity_hash
 report_error_fn
 ret_type
 rewind_source
-- 
2.47.3



  [application/octet-stream] v8-0005-support-2PC.patch (13.9K, ../../TY7PR01MB1455403538191EE4FA429106FF59FA@TY7PR01MB14554.jpnprd01.prod.outlook.com/6-v8-0005-support-2PC.patch)
  download | inline diff:
From 4e76241460a0707dbbeabb81e36723ddacf90bcb Mon Sep 17 00:00:00 2001
From: Hayato Kuroda <[email protected]>
Date: Tue, 2 Dec 2025 13:01:26 +0900
Subject: [PATCH v8 5/9] support 2PC

This patch allows the PREPARE transaction to be applied in parallel. Parallel
apply workers are assigned to a transaction when BEGIN_PREPARE is received. This
part and the dependency-waiting mechanism are the same as a normal transaction.

A parallel worker can be freed after it handles a PREPARE message. The prepared
transaction can be deleted from parallelized_txns at that time; the upcoming
transactions will wait until then.

The leader apply worker resolves COMMIT PREPARED/ROLLBACK PREPARED. Since it can
be serialized automatically, it does not add the transaction to parallelized_txns.
---
 src/backend/replication/logical/worker.c      | 239 ++++++++++++++++--
 src/test/subscription/t/050_parallel_apply.pl |  60 +++++
 2 files changed, 271 insertions(+), 28 deletions(-)

diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index d1ba801f96f..41da800b2e4 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -756,6 +756,14 @@ cleanup_replica_identity_table(List *committed_xid)
 	if (!committed_xid)
 		return;
 
+	/*
+	 * Skip if the replica_identity_table is not initialized yet. This can
+	 * happen if the empty transaction was replicated and a parallel apply
+	 * worker was launched. See comments in apply_handle_prepare().
+	 */
+	if (!replica_identity_table)
+		return;
+
 	replica_identity_start_iterate(replica_identity_table, &i);
 	while ((rientry = replica_identity_iterate(replica_identity_table, &i)) != NULL)
 	{
@@ -2117,6 +2125,11 @@ static void
 apply_handle_begin_prepare(StringInfo s)
 {
 	LogicalRepPreparedTxnData begin_data;
+	ParallelApplyWorkerInfo *winfo;
+	TransApplyAction apply_action;
+
+	/* Save the message before it is consumed. */
+	StringInfoData original_msg = *s;
 
 	/* Tablesync should never receive prepare. */
 	if (am_tablesync_worker())
@@ -2128,12 +2141,61 @@ apply_handle_begin_prepare(StringInfo s)
 	Assert(!TransactionIdIsValid(stream_xid));
 
 	logicalrep_read_begin_prepare(s, &begin_data);
-	set_apply_error_context_xact(begin_data.xid, begin_data.prepare_lsn);
+
+	remote_xid = begin_data.xid;
+
+	set_apply_error_context_xact(remote_xid, begin_data.prepare_lsn);
 
 	remote_final_lsn = begin_data.prepare_lsn;
 
 	maybe_start_skipping_changes(begin_data.prepare_lsn);
 
+	pa_allocate_worker(remote_xid, false);
+
+	apply_action = get_transaction_apply_action(remote_xid, &winfo);
+
+	elog(DEBUG1, "new remote_xid %u", remote_xid);
+	switch (apply_action)
+	{
+		case TRANS_LEADER_APPLY:
+			break;
+
+		case TRANS_LEADER_SEND_TO_PARALLEL:
+			Assert(winfo);
+
+			if (pa_send_data(winfo, s->len, s->data))
+			{
+				pa_set_stream_apply_worker(winfo);
+				break;
+			}
+
+			/*
+			 * Switch to serialize mode when we are not able to send the
+			 * change to parallel apply worker.
+			 */
+			pa_switch_to_partial_serialize(winfo, true);
+
+/* fall through */
+		case TRANS_LEADER_PARTIAL_SERIALIZE:
+			Assert(winfo);
+
+			stream_write_change(LOGICAL_REP_MSG_BEGIN_PREPARE, &original_msg);
+
+			/* Cache the parallel apply worker for this transaction. */
+			pa_set_stream_apply_worker(winfo);
+			break;
+
+		case TRANS_PARALLEL_APPLY:
+			/* Hold the lock until the end of the transaction. */
+			pa_lock_transaction(MyParallelShared->xid, AccessExclusiveLock);
+			pa_set_xact_state(MyParallelShared, PARALLEL_TRANS_STARTED);
+			break;
+
+		default:
+			elog(ERROR, "unexpected apply action: %d", (int) apply_action);
+			break;
+	}
+
 	in_remote_transaction = true;
 
 	pgstat_report_activity(STATE_RUNNING, NULL);
@@ -2183,6 +2245,11 @@ static void
 apply_handle_prepare(StringInfo s)
 {
 	LogicalRepPreparedTxnData prepare_data;
+	ParallelApplyWorkerInfo *winfo;
+	TransApplyAction apply_action;
+
+	/* Save the message before it is consumed. */
+	StringInfoData original_msg = *s;
 
 	logicalrep_read_prepare(s, &prepare_data);
 
@@ -2193,36 +2260,137 @@ apply_handle_prepare(StringInfo s)
 								 LSN_FORMAT_ARGS(prepare_data.prepare_lsn),
 								 LSN_FORMAT_ARGS(remote_final_lsn))));
 
-	/*
-	 * Unlike commit, here, we always prepare the transaction even though no
-	 * change has happened in this transaction or all changes are skipped. It
-	 * is done this way because at commit prepared time, we won't know whether
-	 * we have skipped preparing a transaction because of those reasons.
-	 *
-	 * XXX, We can optimize such that at commit prepared time, we first check
-	 * whether we have prepared the transaction or not but that doesn't seem
-	 * worthwhile because such cases shouldn't be common.
-	 */
-	begin_replication_step();
+	apply_action = get_transaction_apply_action(remote_xid, &winfo);
 
-	apply_handle_prepare_internal(&prepare_data);
+	switch (apply_action)
+	{
+		case TRANS_LEADER_APPLY:
 
-	end_replication_step();
-	CommitTransactionCommand();
-	pgstat_report_stat(false);
+			/*
+			 * Unlike commit, here, we always prepare the transaction even
+			 * though no change has happened in this transaction or all
+			 * changes are skipped. It is done this way because at commit
+			 * prepared time, we won't know whether we have skipped preparing
+			 * a transaction because of those reasons.
+			 *
+			 * XXX, We can optimize such that at commit prepared time, we
+			 * first check whether we have prepared the transaction or not but
+			 * that doesn't seem worthwhile because such cases shouldn't be
+			 * common.
+			 */
+			begin_replication_step();
 
-	/*
-	 * It is okay not to set the local_end LSN for the prepare because we
-	 * always flush the prepare record. So, we can send the acknowledgment of
-	 * the remote_end LSN as soon as prepare is finished.
-	 *
-	 * XXX For the sake of consistency with commit, we could have set it with
-	 * the LSN of prepare but as of now we don't track that value similar to
-	 * XactLastCommitEnd, and adding it for this purpose doesn't seems worth
-	 * it.
-	 */
-	store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr,
-						 InvalidTransactionId);
+			/* Wait until the last transaction finishes */
+			if (TransactionIdIsValid(last_remote_xid))
+				pa_wait_for_depended_transaction(last_remote_xid);
+
+			apply_handle_prepare_internal(&prepare_data);
+
+			end_replication_step();
+			CommitTransactionCommand();
+			pgstat_report_stat(false);
+
+			/*
+			 * It is okay not to set the local_end LSN for the prepare because
+			 * we always flush the prepare record. So, we can send the
+			 * acknowledgment of the remote_end LSN as soon as prepare is
+			 * finished.
+			 *
+			 * XXX For the sake of consistency with commit, we could have set
+			 * it with the LSN of prepare but as of now we don't track that
+			 * value similar to XactLastCommitEnd, and adding it for this
+			 * purpose doesn't seems worth it.
+			 */
+			store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr,
+								 InvalidTransactionId);
+
+			break;
+
+		case TRANS_LEADER_SEND_TO_PARALLEL:
+			Assert(winfo);
+
+			/*
+			 * Mark this transaction as parallelized. This ensures that
+			 * upcoming transactions wait until this transaction is committed.
+			 */
+			pa_add_parallelized_transaction(remote_xid);
+
+			/*
+			 * Build a dependency between this transaction and the lastly
+			 * committed transaction to preserve the commit order. Then try to
+			 * send a COMMIT message if succeeded.
+			 */
+			if (build_dependency_with_last_committed_txn(winfo) &&
+				pa_send_data(winfo, s->len, s->data))
+			{
+				/* Finish processing the transaction. */
+				pa_xact_finish(winfo, prepare_data.end_lsn);
+				break;
+			}
+
+			/*
+			 * Switch to serialize mode when we are not able to send the
+			 * change to parallel apply worker.
+			 */
+			pa_switch_to_partial_serialize(winfo, true);
+/* fall through */
+		case TRANS_LEADER_PARTIAL_SERIALIZE:
+			Assert(winfo);
+
+			stream_open_and_write_change(remote_xid, LOGICAL_REP_MSG_PREPARE,
+										 &original_msg);
+
+			pa_set_fileset_state(winfo->shared, FS_SERIALIZE_DONE);
+
+			/* Finish processing the transaction. */
+			pa_xact_finish(winfo, prepare_data.end_lsn);
+			break;
+
+		case TRANS_PARALLEL_APPLY:
+
+			/*
+			 * If the parallel apply worker is applying spooled messages then
+			 * close the file before committing.
+			 */
+			if (stream_fd)
+				stream_close_file();
+
+			begin_replication_step();
+
+			INJECTION_POINT("parallel-worker-before-prepare", NULL);
+
+			/* Mark the transaction as prepared. */
+			apply_handle_prepare_internal(&prepare_data);
+
+			end_replication_step();
+
+			CommitTransactionCommand();
+			pgstat_report_stat(false);
+
+			store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr,
+								 InvalidTransactionId);
+
+			/*
+			 * It is okay not to set the local_end LSN for the prepare because
+			 * we always flush the prepare record. See apply_handle_prepare.
+			 */
+			MyParallelShared->last_commit_end = InvalidXLogRecPtr;
+			pa_commit_transaction();
+
+			pa_unlock_transaction(MyParallelShared->xid, AccessExclusiveLock);
+
+			pa_reset_subtrans();
+			break;
+
+		default:
+			elog(ERROR, "unexpected apply action: %d", (int) apply_action);
+			break;
+	}
+
+	/* Cache the remote_xid */
+	last_remote_xid = remote_xid;
+
+	remote_xid = InvalidTransactionId;
 
 	in_remote_transaction = false;
 
@@ -2270,6 +2438,9 @@ apply_handle_commit_prepared(StringInfo s)
 	/* There is no transaction when COMMIT PREPARED is called */
 	begin_replication_step();
 
+	if (TransactionIdIsValid(last_remote_xid))
+		pa_wait_for_depended_transaction(last_remote_xid);
+
 	/*
 	 * Update origin state so we can restart streaming from correct position
 	 * in case of crash.
@@ -2282,6 +2453,14 @@ apply_handle_commit_prepared(StringInfo s)
 	CommitTransactionCommand();
 	pgstat_report_stat(false);
 
+	/*
+	 * No need to update last_remote_xid because leader worker applied the
+	 * message thus upcoming transaction preserves the order automatically.
+	 * Let's set the xid to an invalid value to skip sending the
+	 * INTERNAL_DEPENDENCY message.
+	 */
+	last_remote_xid = InvalidTransactionId;
+
 	store_flush_position(prepare_data.end_lsn, XactLastCommitEnd,
 						 InvalidTransactionId);
 	in_remote_transaction = false;
@@ -2338,6 +2517,10 @@ apply_handle_rollback_prepared(StringInfo s)
 
 		/* There is no transaction when ABORT/ROLLBACK PREPARED is called */
 		begin_replication_step();
+
+		if (TransactionIdIsValid(last_remote_xid))
+			pa_wait_for_depended_transaction(last_remote_xid);
+
 		FinishPreparedTransaction(gid, false);
 		end_replication_step();
 		CommitTransactionCommand();
diff --git a/src/test/subscription/t/050_parallel_apply.pl b/src/test/subscription/t/050_parallel_apply.pl
index 69cf48cb7ac..15973f7d0e0 100644
--- a/src/test/subscription/t/050_parallel_apply.pl
+++ b/src/test/subscription/t/050_parallel_apply.pl
@@ -17,6 +17,8 @@ if ($ENV{enable_injection_points} ne 'yes')
 # Initialize publisher node
 my $node_publisher = PostgreSQL::Test::Cluster->new('publisher');
 $node_publisher->init(allows_streaming => 'logical');
+$node_publisher->append_conf('postgresql.conf',
+    "max_prepared_transactions = 10");
 $node_publisher->start;
 
 # Insert initial data
@@ -35,6 +37,8 @@ $node_subscriber->init;
 $node_subscriber->append_conf('postgresql.conf', "log_min_messages = debug1");
 $node_subscriber->append_conf('postgresql.conf',
 	"max_logical_replication_workers = 10");
+$node_subscriber->append_conf('postgresql.conf',
+    "max_prepared_transactions = 10");
 $node_subscriber->start;
 
 # Check if the extension injection_points is available, as it may be
@@ -127,4 +131,60 @@ $result =
     "SELECT count(1) FROM regress_tab WHERE value = 'updated'");
 is ($result, 5, 'updates are also replicated to subscriber');
 
+# Ensure PREPAREd transaction also affects the parallel apply
+
+$node_subscriber->safe_psql('postgres',
+    "ALTER SUBSCRIPTION regress_sub DISABLE;");
+$node_subscriber->poll_query_until('postgres',
+	"SELECT count(*) = 0 FROM pg_stat_activity WHERE backend_type = 'logical replication apply worker'"
+);
+$node_subscriber->safe_psql(
+	'postgres', "
+    ALTER SUBSCRIPTION regress_sub SET (two_phase = on);
+    ALTER SUBSCRIPTION regress_sub ENABLE;");
+
+$result = $node_subscriber->safe_psql('postgres',
+    "SELECT count(1) FROM pg_stat_activity WHERE backend_type = 'logical replication parallel worker'");
+is($result, '0', "no parallel apply workers exist after restart");
+
+# Attach an injection_point. Parallel workers would wait before the prepare
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-prepare','wait');"
+);
+
+# PREPARE a transaction on publisher. It would be handled by a parallel apply
+# worker.
+$node_publisher->safe_psql('postgres', qq[
+    BEGIN;
+    INSERT INTO regress_tab VALUES (generate_series(51, 60), 'prepare');
+    PREPARE TRANSACTION 'regress_prepare';
+]);
+
+# Wait until the parallel worker enters the injection point.
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-prepare');
+
+$offset = -s $node_subscriber->logfile;
+
+# Insert tuples on publisher again. This transaction waits for the prepared
+# transaction
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(61, 70), 'test');");
+
+# Verify the parallel worker waits for the transaction
+$str = $node_subscriber->wait_for_log(qr/wait for depended xid ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /wait for depended xid ([1-9][0-9]+)/;
+
+# Wakeup the parallel worker
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-prepare');
+    SELECT injection_points_wakeup('parallel-worker-before-prepare');
+]);
+
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+
+# COMMIT the prepared transaction. It is always handled by the leader
+$node_publisher->safe_psql('postgres', "COMMIT PREPARED 'regress_prepare';");
+$node_publisher->wait_for_catchup('regress_sub');
+
 done_testing();
-- 
2.47.3



  [application/octet-stream] v8-0006-Track-dependencies-for-streamed-transactions.patch (10.7K, ../../TY7PR01MB1455403538191EE4FA429106FF59FA@TY7PR01MB14554.jpnprd01.prod.outlook.com/7-v8-0006-Track-dependencies-for-streamed-transactions.patch)
  download | inline diff:
From 09d21dd6b69c2e07e584227547713a69ebd5d337 Mon Sep 17 00:00:00 2001
From: Hayato Kuroda <[email protected]>
Date: Thu, 4 Dec 2025 20:55:26 +0900
Subject: [PATCH v8 6/9] Track dependencies for streamed transactions

This commit allows tracking dependencies of streamed transactions.

Regarding the streaming=on case, dependency tracking is enabled while applying
spooled changes from files.

In the streaming=parallel case, dependency tracking is performed when the leader
sends changes to parallel workers. Apart from non-streamed transactions, the
leader waits for parallel workers till the assigned transactions are finished at
COMMIT/PREPARE/ABORT; thus, the XID of streamed transactions is not cached as
the lastly handled one. Also, streamed transactions are not recorded as
parallelized transactions because upcoming workers do not have to wait for them.
---
 .../replication/logical/applyparallelworker.c | 19 +++++-
 src/backend/replication/logical/worker.c      | 66 +++++++++++++++++--
 src/include/replication/worker_internal.h     |  2 +-
 src/test/subscription/t/050_parallel_apply.pl | 47 +++++++++++++
 4 files changed, 126 insertions(+), 8 deletions(-)

diff --git a/src/backend/replication/logical/applyparallelworker.c b/src/backend/replication/logical/applyparallelworker.c
index 3396ee1cdf1..173a81f4d9c 100644
--- a/src/backend/replication/logical/applyparallelworker.c
+++ b/src/backend/replication/logical/applyparallelworker.c
@@ -168,7 +168,14 @@
  * key) as another ongoing transaction (see handle_dependency_on_change for
  * details). If so, the leader sends a list of dependent transaction IDs to the
  * parallel worker, indicating that the parallel apply worker must wait for
- * these transactions to commit before proceeding.
+ * these transactions to commit before proceeding. If transactions are streamed
+ * but leader deciedes no to assign parallel apply workers, dependencies are
+ * verified when the transaction is committed.
+ *
+ * Non-streaming transactions
+ * ======================
+ * The handling is similar to streaming transactions, but including few
+ * differences:
  *
  * Commit order
  * ------------
@@ -1635,6 +1642,12 @@ pa_set_stream_apply_worker(ParallelApplyWorkerInfo *winfo)
 	stream_apply_worker = winfo;
 }
 
+bool
+pa_stream_apply_worker_is_null(void)
+{
+	return stream_apply_worker == NULL;
+}
+
 /*
  * Form a unique savepoint name for the streaming transaction.
  *
@@ -1720,6 +1733,10 @@ pa_stream_abort(LogicalRepStreamAbortData *abort_data)
 	TransactionId xid = abort_data->xid;
 	TransactionId subxid = abort_data->subxid;
 
+	/* Streamed transactions won't be registered */
+	Assert(!dshash_find(parallelized_txns, &xid, false) &&
+		   !dshash_find(parallelized_txns, &subxid, false));
+
 	/*
 	 * Update origin state so we can restart streaming from correct position
 	 * in case of crash.
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 41da800b2e4..7f9d99901c0 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -969,13 +969,26 @@ check_dependency_on_replica_identity(Oid relid,
 													   &rientry->remote_xid,
 													   new_depended_xid);
 
+	/*
+	 * Remove the entry if it is registered for the streamed transactions. We
+	 * do not have to register an entry for them; The leader worker always
+	 * waits until the parallel worker finishes handling streamed
+	 * transactions, thus no need to consider the possiblity that upcoming
+	 * parallel workers would go ahead.
+	 */
+	if (TransactionIdIsValid(stream_xid) && !found)
+	{
+		free_replica_identity_key(rientry->keydata);
+		replica_identity_delete_item(replica_identity_table, rientry);
+	}
+
 	/*
 	 * Update the new depended xid into the entry if valid, the new xid could
 	 * be invalid if the transaction will be applied by the leader itself
 	 * which means all the changes will be committed before processing next
 	 * transaction, so no need to be depended on.
 	 */
-	if (TransactionIdIsValid(new_depended_xid))
+	else if (TransactionIdIsValid(new_depended_xid))
 		rientry->remote_xid = new_depended_xid;
 
 	/*
@@ -1089,8 +1102,11 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 	 */
 	StringInfoData change = *s;
 
-	/* Compute dependency only for non-streaming transaction */
-	if (in_streamed_transaction || (winfo && winfo->stream_txn))
+	/*
+	 * Skip if we are handling streaming transactions but changes are not
+	 * applied yet.
+	 */
+	if (pa_stream_apply_worker_is_null() && in_streamed_transaction)
 		return;
 
 	/* Only the leader checks dependencies and schedules the parallel apply */
@@ -1450,7 +1466,18 @@ handle_streamed_transaction(LogicalRepMsgType action, StringInfo s)
 				(errcode(ERRCODE_PROTOCOL_VIOLATION),
 				 errmsg_internal("invalid transaction ID in streamed replication transaction")));
 
-	handle_dependency_on_change(action, s, current_xid, winfo);
+	/*
+	 * Check dependencies related to the received change. The XID of the top
+	 * transaction is always used to avoid detecting false-positive
+	 * dependencies between top and sub transactions. Sub-transactions can be
+	 * replicated for streamed transactions, and they won't be marked as
+	 * parallelized so that parallel workers won't wait for rolled-back
+	 * sub-transactions.
+	 */
+	handle_dependency_on_change(action, s,
+								in_streamed_transaction
+								? stream_xid : remote_xid,
+								winfo);
 
 	/*
 	 * Re-fetch the latest apply action as it might have been changed during
@@ -2589,6 +2616,10 @@ apply_handle_stream_prepare(StringInfo s)
 			apply_spooled_messages(MyLogicalRepWorker->stream_fileset,
 								   prepare_data.xid, prepare_data.prepare_lsn);
 
+			/* Wait until the last transaction finishes */
+			if (TransactionIdIsValid(last_remote_xid))
+				pa_wait_for_depended_transaction(last_remote_xid);
+
 			/* Mark the transaction as prepared. */
 			apply_handle_prepare_internal(&prepare_data);
 
@@ -2612,7 +2643,8 @@ apply_handle_stream_prepare(StringInfo s)
 		case TRANS_LEADER_SEND_TO_PARALLEL:
 			Assert(winfo);
 
-			if (pa_send_data(winfo, s->len, s->data))
+			if (build_dependency_with_last_committed_txn(winfo) &&
+				pa_send_data(winfo, s->len, s->data))
 			{
 				/* Finish processing the streaming transaction. */
 				pa_xact_finish(winfo, prepare_data.end_lsn);
@@ -2678,6 +2710,11 @@ apply_handle_stream_prepare(StringInfo s)
 
 	pgstat_report_stat(false);
 
+	/*
+	 * No need to update the last_remote_xid here because leader worker always
+	 * wait until streamed transactions finish.
+	 */
+
 	/*
 	 * Process any tables that are being synchronized in parallel, as well as
 	 * any newly added tables or sequences.
@@ -3462,6 +3499,10 @@ apply_handle_stream_commit(StringInfo s)
 			apply_spooled_messages(MyLogicalRepWorker->stream_fileset, xid,
 								   commit_data.commit_lsn);
 
+			/* Wait until the last transaction finishes */
+			if (TransactionIdIsValid(last_remote_xid))
+				pa_wait_for_depended_transaction(last_remote_xid);
+
 			apply_handle_commit_internal(&commit_data);
 
 			/* Unlink the files with serialized changes and subxact info. */
@@ -3473,7 +3514,20 @@ apply_handle_stream_commit(StringInfo s)
 		case TRANS_LEADER_SEND_TO_PARALLEL:
 			Assert(winfo);
 
-			if (pa_send_data(winfo, s->len, s->data))
+			/*
+			 * Apart from non-streaming case, no need to mark this transaction
+			 * as parallelized. Because the leader waits until the streamed
+			 * transaction is committed thus commit ordering is always
+			 * preserved.
+			 */
+
+			/*
+			 * Build a dependency between this transaction and the lastly
+			 * committed transaction to preserve the commit order. Then try to
+			 * send a COMMIT message if succeeded.
+			 */
+			if (build_dependency_with_last_committed_txn(winfo) &&
+				pa_send_data(winfo, s->len, s->data))
 			{
 				/* Finish processing the streaming transaction. */
 				pa_xact_finish(winfo, commit_data.end_lsn);
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index d5bf4598980..5d7143b79b1 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -354,7 +354,7 @@ extern void pa_switch_to_partial_serialize(ParallelApplyWorkerInfo *winfo,
 extern void pa_set_xact_state(ParallelApplyWorkerShared *wshared,
 							  ParallelTransState xact_state);
 extern void pa_set_stream_apply_worker(ParallelApplyWorkerInfo *winfo);
-
+extern bool pa_stream_apply_worker_is_null(void);
 extern void pa_start_subtrans(TransactionId current_xid,
 							  TransactionId top_xid);
 extern void pa_reset_subtrans(void);
diff --git a/src/test/subscription/t/050_parallel_apply.pl b/src/test/subscription/t/050_parallel_apply.pl
index 15973f7d0e0..9254b85d350 100644
--- a/src/test/subscription/t/050_parallel_apply.pl
+++ b/src/test/subscription/t/050_parallel_apply.pl
@@ -187,4 +187,51 @@ $node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset
 $node_publisher->safe_psql('postgres', "COMMIT PREPARED 'regress_prepare';");
 $node_publisher->wait_for_catchup('regress_sub');
 
+# Ensure streamed transactions waits the previous transaction
+
+$node_publisher->append_conf('postgresql.conf',
+   "logical_decoding_work_mem = 64kB");
+$node_publisher->reload;
+# Run a query to make sure that the reload has taken effect.
+$node_publisher->safe_psql('postgres', "SELECT 1");
+
+# Attach the injection_point again
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(71, 80), 'test');");
+
+# Wait until the parallel worker enters the injection point.
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+# Run a transaction which would be streamed
+my $h = $node_publisher->background_psql('postgres', on_error_stop => 0);
+
+$offset = -s $node_subscriber->logfile;
+
+$h->query_safe(
+	q{
+BEGIN;
+UPDATE regress_tab SET value = 'streamed-updated' WHERE id BETWEEN 71 AND 80;
+INSERT INTO regress_tab VALUES (generate_series(100, 5100), 'streamed');
+});
+
+# Verify the parallel worker waits for the transaction
+$str = $node_subscriber->wait_for_log(qr/wait for depended xid ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /wait for depended xid ([1-9][0-9]+)/;
+
+# Wakeup the parallel worker
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+# Verify the streamed transaction can be applied
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+
+$h->query_safe("COMMIT;");
+
 done_testing();
-- 
2.47.3



  [application/octet-stream] v8-0007-Wait-applying-transaction-if-one-of-user-defined-.patch (11.8K, ../../TY7PR01MB1455403538191EE4FA429106FF59FA@TY7PR01MB14554.jpnprd01.prod.outlook.com/8-v8-0007-Wait-applying-transaction-if-one-of-user-defined-.patch)
  download | inline diff:
From 88970abe40b78327f2280089071f2c9c1c71d0e3 Mon Sep 17 00:00:00 2001
From: Hayato Kuroda <[email protected]>
Date: Tue, 23 Dec 2025 17:58:15 +0900
Subject: [PATCH v8 7/9] Wait applying transaction if one of user-defined
 triggers is immutable

Since many parallel workers apply transactions, triggers for relations can also
be fired in parallel, which may obtain unexpected results. To make it safe,
parallel apply workers wait for the previously dispatched transaction before
applying changes to the relation that has mutable triggers.
---
 src/backend/replication/logical/relation.c | 123 ++++++++++++++++++---
 src/backend/replication/logical/worker.c   |  68 ++++++++++++
 src/include/replication/logicalrelation.h  |  20 ++++
 3 files changed, 197 insertions(+), 14 deletions(-)

diff --git a/src/backend/replication/logical/relation.c b/src/backend/replication/logical/relation.c
index e1ce183dfd3..eeb85f8cc5d 100644
--- a/src/backend/replication/logical/relation.c
+++ b/src/backend/replication/logical/relation.c
@@ -21,7 +21,9 @@
 #include "access/genam.h"
 #include "access/table.h"
 #include "catalog/namespace.h"
+#include "catalog/pg_proc.h"
 #include "catalog/pg_subscription_rel.h"
+#include "commands/trigger.h"
 #include "executor/executor.h"
 #include "nodes/makefuncs.h"
 #include "replication/logicalrelation.h"
@@ -160,6 +162,10 @@ logicalrep_relmap_free_entry(LogicalRepRelMapEntry *entry)
  *
  * Called when new relation mapping is sent by the publisher to update
  * our expected view of incoming data from said publisher.
+ *
+ * Note that we do not check the user-defined constraints here. PostgreSQL has
+ * already assumed that CHECK constraints' conditions are immutable and here
+ * follows the rule.
  */
 void
 logicalrep_relmap_update(LogicalRepRelation *remoterel)
@@ -209,6 +215,8 @@ logicalrep_relmap_update(LogicalRepRelation *remoterel)
 		(remoterel->relkind == 0) ? RELKIND_RELATION : remoterel->relkind;
 
 	entry->remoterel.attkeys = bms_copy(remoterel->attkeys);
+
+	entry->parallel_safe = LOGICALREP_PARALLEL_UNKNOWN;
 	MemoryContextSwitchTo(oldctx);
 }
 
@@ -354,27 +362,79 @@ logicalrep_rel_mark_updatable(LogicalRepRelMapEntry *entry)
 }
 
 /*
- * Open the local relation associated with the remote one.
+ * Check all local triggers for the relation to see the parallelizability.
  *
- * Rebuilds the Relcache mapping if it was invalidated by local DDL.
+ * We regard relations as applicable in parallel if all triggers are immutable.
+ * Result is directly set to LogicalRepRelMapEntry::parallel_safe.
  */
-LogicalRepRelMapEntry *
-logicalrep_rel_open(LogicalRepRelId remoteid, LOCKMODE lockmode)
+static void
+check_defined_triggers(LogicalRepRelMapEntry *entry)
+{
+	TriggerDesc *trigdesc = entry->localrel->trigdesc;
+
+	/* Quick exit if triffer is not defined */
+	if (trigdesc == NULL)
+	{
+		entry->parallel_safe = LOGICALREP_PARALLEL_SAFE;
+		return;
+	}
+
+	/* Seek triggers one by one to see the volatility */
+	for (int i = 0; i < trigdesc->numtriggers; i++)
+	{
+		Trigger    *trigger = &trigdesc->triggers[i];
+
+		Assert(OidIsValid(trigger->tgfoid));
+
+		/* Skip if the trigger is not enabled for logical replication */
+		if (trigger->tgenabled == TRIGGER_DISABLED ||
+			trigger->tgenabled == TRIGGER_FIRES_ON_ORIGIN)
+			continue;
+
+		/* Check the volatility of the trigger. Exit if it is not immutable */
+		if (func_volatile(trigger->tgfoid) != PROVOLATILE_IMMUTABLE)
+		{
+			entry->parallel_safe = LOGICALREP_PARALLEL_RESTRICTED;
+			return;
+		}
+	}
+
+	/* All triggers are immutable, set as parallel safe */
+	entry->parallel_safe = LOGICALREP_PARALLEL_SAFE;
+}
+
+/*
+ * Actual workhorse for logicalrep_rel_open().
+ *
+ * Caller must specify *either* entry or key. If the entry is specified, its
+ * attributes are filled and returned. The logical relation is kept opening.
+ * If the key is given, the corresponding entry is first searched in the hash
+ * table and processed as in the above case. At the end, logical replication is
+ * closed.
+  */
+void
+logicalrep_rel_load(LogicalRepRelMapEntry *entry, LogicalRepRelId remoteid,
+					LOCKMODE lockmode)
 {
-	LogicalRepRelMapEntry *entry;
-	bool		found;
 	LogicalRepRelation *remoterel;
 
-	if (LogicalRepRelMap == NULL)
-		logicalrep_relmap_init();
+	Assert((entry && !remoteid) || (!entry && remoteid));
 
-	/* Search for existing entry. */
-	entry = hash_search(LogicalRepRelMap, &remoteid,
-						HASH_FIND, &found);
+	if (!entry)
+	{
+		bool		found;
 
-	if (!found)
-		elog(ERROR, "no relation map entry for remote relation ID %u",
-			 remoteid);
+		if (LogicalRepRelMap == NULL)
+			logicalrep_relmap_init();
+
+		/* Search for existing entry. */
+		entry = hash_search(LogicalRepRelMap, &remoteid,
+							HASH_FIND, &found);
+
+		if (!found)
+			elog(ERROR, "no relation map entry for remote relation ID %u",
+				 remoteid);
+	}
 
 	remoterel = &entry->remoterel;
 
@@ -500,6 +560,13 @@ logicalrep_rel_open(LogicalRepRelId remoteid, LOCKMODE lockmode)
 		entry->localindexoid = FindLogicalRepLocalIndex(entry->localrel, remoterel,
 														entry->attrmap);
 
+		/*
+		 * Leader must also collect all local unique indexes for dependency
+		 * tracking.
+		 */
+		if (am_leader_apply_worker())
+			check_defined_triggers(entry);
+
 		entry->localrelvalid = true;
 	}
 
@@ -508,6 +575,34 @@ logicalrep_rel_open(LogicalRepRelId remoteid, LOCKMODE lockmode)
 											   entry->localreloid,
 											   &entry->statelsn);
 
+	if (remoteid)
+		logicalrep_rel_close(entry, lockmode);
+}
+
+/*
+ * Open the local relation associated with the remote one.
+ *
+ * Rebuilds the Relcache mapping if it was invalidated by local DDL.
+ */
+LogicalRepRelMapEntry *
+logicalrep_rel_open(LogicalRepRelId remoteid, LOCKMODE lockmode)
+{
+	LogicalRepRelMapEntry *entry;
+	bool		found;
+
+	if (LogicalRepRelMap == NULL)
+		logicalrep_relmap_init();
+
+	/* Search for existing entry. */
+	entry = hash_search(LogicalRepRelMap, &remoteid,
+						HASH_FIND, &found);
+
+	if (!found)
+		elog(ERROR, "no relation map entry for remote relation ID %u",
+			 remoteid);
+
+	logicalrep_rel_load(entry, 0, lockmode);
+
 	return entry;
 }
 
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 7f9d99901c0..c12b83a05d5 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -1070,6 +1070,59 @@ check_dependency_on_rel(LogicalRepRelId relid, TransactionId new_depended_xid,
 		relentry->last_depended_xid = new_depended_xid;
 }
 
+/*
+ * Check the parallelizability of applying changes for the relation.
+ * Append the lastly dispatched transaction in in 'depends_on_xids' if the
+ * relation is parallel unsafe.
+ */
+static void
+check_dependency_for_parallel_safety(LogicalRepRelId relid,
+									 TransactionId new_depended_xid,
+									 List **depends_on_xids)
+{
+	LogicalRepRelMapEntry *relentry;
+
+	/* Quick exit if no transactions have been dispatched */
+	if (!TransactionIdIsValid(last_remote_xid))
+		return;
+
+	relentry = logicalrep_get_relentry(relid);
+
+	/*
+	 * Gather information for local triggres if not yet. We require to be in a
+	 * transaction state because system catalogs are read.
+	 */
+	if (relentry->parallel_safe == LOGICALREP_PARALLEL_UNKNOWN)
+	{
+		bool		needs_start = !IsTransactionOrTransactionBlock();
+
+		if (needs_start)
+			StartTransactionCommand();
+
+		logicalrep_rel_load(NULL, relid, AccessShareLock);
+
+		/*
+		 * Close the transaction if we start here. We must not abort because
+		 * it would release all session-level locks, such as the stream lock,
+		 * and break the deadlock detection mechanism between LA and PA. The
+		 * outcome is the same regardless of the end status, since the
+		 * transaction did not modify any tuples.
+		 */
+		if (needs_start)
+			CommitTransactionCommand();
+
+		Assert(relentry->parallel_safe != LOGICALREP_PARALLEL_UNKNOWN);
+	}
+
+	/* Do nothing for parallel safe relations */
+	if (relentry->parallel_safe == LOGICALREP_PARALLEL_SAFE)
+		return;
+
+	*depends_on_xids = check_and_append_xid_dependency(*depends_on_xids,
+													   &last_remote_xid,
+													   new_depended_xid);
+}
+
 /*
  * Check dependencies related to the current change by determining if the
  * modification impacts the same row or table as another ongoing transaction. If
@@ -1128,6 +1181,8 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			check_dependency_on_replica_identity(relid, &newtup,
 												 new_depended_xid,
 												 &depends_on_xids);
+			check_dependency_for_parallel_safety(relid, new_depended_xid,
+												 &depends_on_xids);
 			break;
 
 		case LOGICAL_REP_MSG_UPDATE:
@@ -1135,13 +1190,19 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 										   &newtup);
 
 			if (has_oldtup)
+			{
 				check_dependency_on_replica_identity(relid, &oldtup,
 													 new_depended_xid,
 													 &depends_on_xids);
+				check_dependency_for_parallel_safety(relid, new_depended_xid,
+													 &depends_on_xids);
+			}
 
 			check_dependency_on_replica_identity(relid, &newtup,
 												 new_depended_xid,
 												 &depends_on_xids);
+			check_dependency_for_parallel_safety(relid, new_depended_xid,
+												 &depends_on_xids);
 			break;
 
 		case LOGICAL_REP_MSG_DELETE:
@@ -1149,6 +1210,8 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			check_dependency_on_replica_identity(relid, &oldtup,
 												 new_depended_xid,
 												 &depends_on_xids);
+			check_dependency_for_parallel_safety(relid, new_depended_xid,
+												 &depends_on_xids);
 			break;
 
 		case LOGICAL_REP_MSG_TRUNCATE:
@@ -1161,8 +1224,13 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			 * modified the same table.
 			 */
 			foreach_int(truncated_relid, remote_relids)
+			{
 				check_dependency_on_rel(truncated_relid, new_depended_xid,
 										&depends_on_xids);
+				check_dependency_for_parallel_safety(truncated_relid,
+													 new_depended_xid,
+													 &depends_on_xids);
+			}
 
 			break;
 
diff --git a/src/include/replication/logicalrelation.h b/src/include/replication/logicalrelation.h
index 27e365f84c2..916d21c2dbd 100644
--- a/src/include/replication/logicalrelation.h
+++ b/src/include/replication/logicalrelation.h
@@ -39,6 +39,20 @@ typedef struct LogicalRepRelMapEntry
 	XLogRecPtr	statelsn;
 
 	TransactionId last_depended_xid;
+
+	/*
+	 * Whether the relation can be applied in parallel or not. It is
+	 * distinglish whether defined triggers are the immutable or not.
+	 *
+	 * Theoretically, we can determine the parallelizability for each type of
+	 * replication messages, INSERT/UPDATE/DELETE/TRUNCATE. But it is not done
+	 * yet to reduce the number of attributes.
+	 *
+	 * Note that we do not check the user-defined constraints here. PostgreSQL
+	 * has already assumed that CHECK constraints' conditions are immutable and
+	 * here follows the rule.
+	 */
+	char		parallel_safe;
 } LogicalRepRelMapEntry;
 
 extern void logicalrep_relmap_update(LogicalRepRelation *remoterel);
@@ -46,6 +60,8 @@ extern void logicalrep_partmap_reset_relmap(LogicalRepRelation *remoterel);
 
 extern LogicalRepRelMapEntry *logicalrep_rel_open(LogicalRepRelId remoteid,
 												  LOCKMODE lockmode);
+extern void logicalrep_rel_load(LogicalRepRelMapEntry *entry,
+								LogicalRepRelId remoteid, LOCKMODE lockmode);
 extern LogicalRepRelMapEntry *logicalrep_partition_open(LogicalRepRelMapEntry *root,
 														Relation partrel, AttrMap *map);
 extern void logicalrep_rel_close(LogicalRepRelMapEntry *rel,
@@ -56,4 +72,8 @@ extern int	logicalrep_get_num_rels(void);
 extern void logicalrep_write_all_rels(StringInfo out);
 extern LogicalRepRelMapEntry *logicalrep_get_relentry(LogicalRepRelId remoteid);
 
+#define LOGICALREP_PARALLEL_SAFE		's'
+#define LOGICALREP_PARALLEL_RESTRICTED	'r'
+#define LOGICALREP_PARALLEL_UNKNOWN		'u'
+
 #endif							/* LOGICALRELATION_H */
-- 
2.47.3



  [application/octet-stream] v8-0008-Support-dependency-tracking-via-local-unique-inde.patch (23.8K, ../../TY7PR01MB1455403538191EE4FA429106FF59FA@TY7PR01MB14554.jpnprd01.prod.outlook.com/9-v8-0008-Support-dependency-tracking-via-local-unique-inde.patch)
  download | inline diff:
From 9061a5fe6d79ef729698485c82245ee2e7f5ae0f Mon Sep 17 00:00:00 2001
From: Hayato Kuroda <[email protected]>
Date: Thu, 11 Dec 2025 22:21:47 +0900
Subject: [PATCH v8 8/9] Support dependency tracking via local unique indexes

Currently, logical replication's parallel apply mechanism tracks dependencies
primarily based on the REPLICA IDENTITY defined on the publisher table.
However, local subscriber tables might have additional unique indexes that
could effectively serve as dependency keys, even if they don't correspond to
the publisher's REPLICA IDENTITY. Failing to track these additional unique
keys can lead to incorrect data and/or deadlocks during parallel application.

This patch extends the parallel apply's dependency tracking to consider
local unique indexes on the subscriber table. This is achieved by extending
the existing Replica Identity hash table to also store dependency information
based on these local unique indexes.

The LogicalRepRelMapEntry structure is extended to store details about these
local unique indexes. This information is collected and cached when
dependency checking is first performed for a remote transaction on a given
relation. This collection process requires to be in a transaction to access
system catalog information.
---
 src/backend/replication/logical/relation.c    | 161 ++++++++++-
 src/backend/replication/logical/worker.c      | 270 ++++++++++++++----
 src/backend/storage/lmgr/deadlock.c           |   1 -
 src/include/replication/logicalrelation.h     |  14 +
 src/test/subscription/t/050_parallel_apply.pl |  57 ++++
 src/tools/pgindent/typedefs.list              |   2 +
 6 files changed, 446 insertions(+), 59 deletions(-)

diff --git a/src/backend/replication/logical/relation.c b/src/backend/replication/logical/relation.c
index eeb85f8cc5d..6665f5c0cc9 100644
--- a/src/backend/replication/logical/relation.c
+++ b/src/backend/replication/logical/relation.c
@@ -128,6 +128,21 @@ logicalrep_relmap_init(void)
 								  (Datum) 0);
 }
 
+/*
+ * Release local index list
+ */
+static void
+free_local_unique_indexes(LogicalRepRelMapEntry *entry)
+{
+	Assert(am_leader_apply_worker());
+
+	foreach_ptr(LogicalRepSubscriberIdx, idxinfo, entry->local_unique_indexes)
+		bms_free(idxinfo->indexkeys);
+
+	list_free_deep(entry->local_unique_indexes);
+	entry->local_unique_indexes = NIL;
+}
+
 /*
  * Free the entry of a relation map cache.
  */
@@ -155,6 +170,9 @@ logicalrep_relmap_free_entry(LogicalRepRelMapEntry *entry)
 
 	if (entry->attrmap)
 		free_attrmap(entry->attrmap);
+
+	if (entry->local_unique_indexes != NIL)
+		free_local_unique_indexes(entry);
 }
 
 /*
@@ -361,6 +379,126 @@ logicalrep_rel_mark_updatable(LogicalRepRelMapEntry *entry)
 	}
 }
 
+/*
+ * Collect all local unique indexes that can be used for dependency tracking.
+ */
+static void
+collect_local_indexes(LogicalRepRelMapEntry *entry)
+{
+	List	   *idxlist;
+
+	if (entry->local_unique_indexes != NIL)
+		free_local_unique_indexes(entry);
+
+	entry->local_unique_indexes_collected = true;
+
+	idxlist = RelationGetIndexList(entry->localrel);
+
+	/* Quick exit if there are no indexes */
+	if (idxlist == NIL)
+		return;
+
+	/* Iterate indexes to list all usable indexes */
+	foreach_oid(idxoid, idxlist)
+	{
+		Relation	idxrel;
+		int			indnkeys;
+		AttrMap    *attrmap;
+		Bitmapset  *indexkeys = NULL;
+		bool		suitable = true;
+
+		idxrel = index_open(idxoid, AccessShareLock);
+
+		/*
+		 * Check whether the index can be used for the dependency tracking.
+		 *
+		 * For simplification, the same condition as REPLICA IDENTITY FULL,
+		 * plus it must be a unique index.
+		 */
+		if (!(idxrel->rd_index->indisunique &&
+			  IsIndexUsableForReplicaIdentityFull(idxrel, entry->attrmap)))
+		{
+			index_close(idxrel, AccessShareLock);
+			continue;
+		}
+
+		indnkeys = idxrel->rd_index->indnkeyatts;
+		attrmap = entry->attrmap;
+
+		Assert(indnkeys);
+
+		/* Seek each attributes and add to a Bitmap */
+		for (int i = 0; i < indnkeys; i++)
+		{
+			AttrNumber	localcol = idxrel->rd_index->indkey.values[i];
+			AttrNumber	remotecol;
+
+			/*
+			 * XXX: Mark a relation as parallel-unsafe if it has expression
+			 * indexes because we cannot compute the hash value for the
+			 * dependency tracking. For safety, transactions that modify such
+			 * tables can wait for applications till the lastly dispatched
+			 * transaction is committed.
+			 */
+			if (!AttributeNumberIsValid(localcol))
+			{
+				entry->parallel_safe = LOGICALREP_PARALLEL_RESTRICTED;
+				suitable = false;
+				break;
+			}
+
+			remotecol = attrmap->attnums[AttrNumberGetAttrOffset(localcol)];
+
+			/*
+			 * Skip if the column does not exist on publisher node. In this
+			 * case the replicated tuples always have NULL or default value.
+			 */
+			if (remotecol < 0)
+			{
+				suitable = false;
+				break;
+			}
+
+			/* Checks are passed, remember the attribute */
+			indexkeys = bms_add_member(indexkeys, remotecol);
+		}
+
+		index_close(idxrel, AccessShareLock);
+
+		/*
+		 * Skip using the index if it is not suitable. This can happen if 1)
+		 * one of the columns does not exist on the publisher side, or 2)
+		 * there is an expression column.
+		 */
+		if (!suitable)
+		{
+			if (indexkeys)
+				bms_free(indexkeys);
+
+			continue;
+		}
+
+		/* This index is usable, store on memory */
+		if (indexkeys)
+		{
+			MemoryContext oldctx;
+			LogicalRepSubscriberIdx *idxinfo;
+
+			oldctx = MemoryContextSwitchTo(LogicalRepRelMapContext);
+			idxinfo = palloc(sizeof(LogicalRepSubscriberIdx));
+			idxinfo->indexoid = idxoid;
+			idxinfo->indexkeys = bms_copy(indexkeys);
+			entry->local_unique_indexes =
+				lappend(entry->local_unique_indexes, idxinfo);
+
+			pfree(indexkeys);
+			MemoryContextSwitchTo(oldctx);
+		}
+	}
+
+	list_free(idxlist);
+}
+
 /*
  * Check all local triggers for the relation to see the parallelizability.
  *
@@ -370,7 +508,16 @@ logicalrep_rel_mark_updatable(LogicalRepRelMapEntry *entry)
 static void
 check_defined_triggers(LogicalRepRelMapEntry *entry)
 {
-	TriggerDesc *trigdesc = entry->localrel->trigdesc;
+	TriggerDesc *trigdesc;
+
+	/*
+	 * Skip if the parallelizability has already been checked. Possilble if
+	 * the relation has expression indexes.
+	 */
+	if (entry->parallel_safe != LOGICALREP_PARALLEL_UNKNOWN)
+		return;
+
+	trigdesc = entry->localrel->trigdesc;
 
 	/* Quick exit if triffer is not defined */
 	if (trigdesc == NULL)
@@ -411,7 +558,7 @@ check_defined_triggers(LogicalRepRelMapEntry *entry)
  * If the key is given, the corresponding entry is first searched in the hash
  * table and processed as in the above case. At the end, logical replication is
  * closed.
-  */
+ */
 void
 logicalrep_rel_load(LogicalRepRelMapEntry *entry, LogicalRepRelId remoteid,
 					LOCKMODE lockmode)
@@ -565,7 +712,11 @@ logicalrep_rel_load(LogicalRepRelMapEntry *entry, LogicalRepRelId remoteid,
 		 * tracking.
 		 */
 		if (am_leader_apply_worker())
+		{
+			entry->parallel_safe = LOGICALREP_PARALLEL_UNKNOWN;
+			collect_local_indexes(entry);
 			check_defined_triggers(entry);
+		}
 
 		entry->localrelvalid = true;
 	}
@@ -867,6 +1018,12 @@ logicalrep_partition_open(LogicalRepRelMapEntry *root,
 	entry->localindexoid = FindLogicalRepLocalIndex(partrel, remoterel,
 													entry->attrmap);
 
+	/*
+	 * TODO: Parallel apply does not support the parallel apply for now. Just
+	 * mark local indexes are collected.
+	 */
+	entry->local_unique_indexes_collected = true;
+
 	entry->localrelvalid = true;
 
 	return entry;
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index c12b83a05d5..6851596625e 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -548,9 +548,19 @@ typedef struct ApplySubXactData
 
 static ApplySubXactData subxact_data = {0, 0, InvalidTransactionId, NULL};
 
+/*
+ * Type of key used for dependency tracking.
+ */
+typedef enum LogicalRepKeyKind
+{
+	LOGICALREP_KEY_REPLICA_IDENTITY,
+	LOGICALREP_KEY_LOCAL_UNIQUE
+} LogicalRepKeyKind;
+
 typedef struct ReplicaIdentityKey
 {
 	Oid			relid;
+	LogicalRepKeyKind kind;
 	LogicalRepTupleData *data;
 } ReplicaIdentityKey;
 
@@ -710,7 +720,8 @@ static bool
 hash_replica_identity_compare(ReplicaIdentityKey *a, ReplicaIdentityKey *b)
 {
 	if (a->relid != b->relid ||
-		a->data->ncols != b->data->ncols)
+		a->data->ncols != b->data->ncols ||
+		a->kind != b->kind)
 		return false;
 
 	for (int i = 0; i < a->data->ncols; i++)
@@ -718,6 +729,9 @@ hash_replica_identity_compare(ReplicaIdentityKey *a, ReplicaIdentityKey *b)
 		if (a->data->colstatus[i] != b->data->colstatus[i])
 			return false;
 
+		if (a->data->colstatus[i] == LOGICALREP_COLUMN_NULL)
+			continue;
+
 		if (a->data->colvalues[i].len != b->data->colvalues[i].len)
 			return false;
 
@@ -847,6 +861,93 @@ check_and_append_xid_dependency(List *depends_on_xids,
 	return lappend_xid(depends_on_xids, *depends_on_xid);
 }
 
+/*
+ * Common function for checking dependency by using the key. Used by both
+ * check_dependency_on_replica_identity and check_dependency_on_local_key.
+ */
+static void
+check_dependency_by_key(ReplicaIdentityKey *key, TransactionId new_depended_xid,
+						List **depends_on_xids)
+{
+	ReplicaIdentityEntry *rientry;
+	bool		found = false;
+	MemoryContext oldctx;
+
+	oldctx = MemoryContextSwitchTo(ApplyContext);
+
+	if (TransactionIdIsValid(new_depended_xid))
+	{
+		rientry = replica_identity_insert(replica_identity_table, key,
+										  &found);
+
+		/*
+		 * Release the key built to search the entry, if the entry already
+		 * exists. Otherwise, initialize the remote_xid.
+		 */
+		if (found)
+		{
+			elog(DEBUG1,
+				 key->kind == LOGICALREP_KEY_REPLICA_IDENTITY ?
+				 "found conflicting replica identity change from %u" :
+				 "found conflicting local unique change from %u",
+				 rientry->remote_xid);
+
+			free_replica_identity_key(key);
+		}
+		else
+			rientry->remote_xid = InvalidTransactionId;
+	}
+	else
+	{
+		rientry = replica_identity_lookup(replica_identity_table, key);
+		free_replica_identity_key(key);
+	}
+
+	MemoryContextSwitchTo(oldctx);
+
+	/* Return if no entry found */
+	if (!rientry)
+		return;
+
+	Assert(!found || TransactionIdIsValid(rientry->remote_xid));
+
+	*depends_on_xids = check_and_append_xid_dependency(*depends_on_xids,
+													   &rientry->remote_xid,
+													   new_depended_xid);
+
+	/*
+	 * Remove the entry if it is registered for the streamed transactions. We
+	 * do not have to register an entry for them; The leader worker always
+	 * waits until the parallel worker finishes handling streamed
+	 * transactions, thus no need to consider the possiblity that upcoming
+	 * parallel workers would go ahead.
+	 */
+	if (TransactionIdIsValid(stream_xid) && !found)
+	{
+		free_replica_identity_key(rientry->keydata);
+		replica_identity_delete_item(replica_identity_table, rientry);
+	}
+
+	/*
+	 * Update the new depended xid into the entry if valid, the new xid could
+	 * be invalid if the transaction will be applied by the leader itself
+	 * which means all the changes will be committed before processing next
+	 * transaction, so no need to be depended on.
+	 */
+	else if (TransactionIdIsValid(new_depended_xid))
+		rientry->remote_xid = new_depended_xid;
+
+	/*
+	 * Remove the entry if the transaction has been committed and no new
+	 * dependency needs to be added.
+	 */
+	else if (!TransactionIdIsValid(rientry->remote_xid))
+	{
+		free_replica_identity_key(rientry->keydata);
+		replica_identity_delete_item(replica_identity_table, rientry);
+	}
+}
+
 /*
  * Check for dependencies on preceding transactions that modify the same key.
  * Returns the dependent transactions in 'depends_on_xids' and records the
@@ -861,10 +962,8 @@ check_dependency_on_replica_identity(Oid relid,
 	LogicalRepRelMapEntry *relentry;
 	LogicalRepTupleData *ridata;
 	ReplicaIdentityKey *rikey;
-	ReplicaIdentityEntry *rientry;
 	MemoryContext oldctx;
 	int			n_ri;
-	bool		found = false;
 
 	Assert(depends_on_xids);
 
@@ -930,75 +1029,122 @@ check_dependency_on_replica_identity(Oid relid,
 
 	rikey = palloc0_object(ReplicaIdentityKey);
 	rikey->relid = relid;
+	rikey->kind = LOGICALREP_KEY_REPLICA_IDENTITY;
 	rikey->data = ridata;
 
-	if (TransactionIdIsValid(new_depended_xid))
+	MemoryContextSwitchTo(oldctx);
+
+	check_dependency_by_key(rikey, new_depended_xid, depends_on_xids);
+}
+
+/*
+ * Mostly same as check_dependency_on_replica_identity() but for local unique
+ * indexes.
+ */
+static void
+check_dependency_on_local_key(Oid relid,
+							  LogicalRepTupleData *original_data,
+							  TransactionId new_depended_xid,
+							  List **depends_on_xids)
+{
+	LogicalRepRelMapEntry *relentry;
+	LogicalRepTupleData *ridata;
+	ReplicaIdentityKey *rikey;
+	MemoryContext oldctx;
+
+	Assert(depends_on_xids);
+
+	/* Search for existing entry */
+	relentry = logicalrep_get_relentry(relid);
+
+	Assert(relentry);
+
+	/*
+	 * Gather information for local indexes if not yet. We require to be in a
+	 * transaction state because system catalogs are read.
+	 */
+	if (!relentry->local_unique_indexes_collected)
 	{
-		rientry = replica_identity_insert(replica_identity_table, rikey,
-										  &found);
+		bool		needs_start = !IsTransactionOrTransactionBlock();
+
+		if (needs_start)
+			StartTransactionCommand();
+
+		logicalrep_rel_load(NULL, relid, AccessShareLock);
 
 		/*
-		 * Release the key built to search the entry, if the entry already
-		 * exists. Otherwise, initialize the remote_xid.
+		 * Close the transaction if we start here. We must not abort because
+		 * it would release all session-level locks, such as the stream lock,
+		 * and break the deadlock detection mechanism between LA and PA. The
+		 * outcome is the same regardless of the end status, since the
+		 * transaction did not modify any tuples.
 		 */
-		if (found)
-		{
-			elog(DEBUG1, "found conflicting replica identity change from %u",
-				 rientry->remote_xid);
+		if (needs_start)
+			CommitTransactionCommand();
 
-			free_replica_identity_key(rikey);
-		}
-		else
-			rientry->remote_xid = InvalidTransactionId;
+		Assert(relentry->local_unique_indexes_collected);
 	}
-	else
+
+	foreach_ptr(LogicalRepSubscriberIdx, idxinfo, relentry->local_unique_indexes)
 	{
-		rientry = replica_identity_lookup(replica_identity_table, rikey);
-		free_replica_identity_key(rikey);
-	}
+		int			columns = bms_num_members(idxinfo->indexkeys);
+		bool		suitable = true;
 
-	MemoryContextSwitchTo(oldctx);
+		Assert(columns);
 
-	/* Return if no entry found */
-	if (!rientry)
-		return;
+		for (int i = 0; i < original_data->ncols; i++)
+		{
+			if (!bms_is_member(i, idxinfo->indexkeys))
+				continue;
 
-	Assert(!found || TransactionIdIsValid(rientry->remote_xid));
+			/*
+			 * Skip if the column is not changed.
+			 *
+			 * XXX: NULL is allowed.
+			 */
+			if (original_data->colstatus[i] == LOGICALREP_COLUMN_UNCHANGED)
+			{
+				suitable = false;
+				break;
+			}
+		}
 
-	*depends_on_xids = check_and_append_xid_dependency(*depends_on_xids,
-													   &rientry->remote_xid,
-													   new_depended_xid);
+		if (!suitable)
+			continue;
 
-	/*
-	 * Remove the entry if it is registered for the streamed transactions. We
-	 * do not have to register an entry for them; The leader worker always
-	 * waits until the parallel worker finishes handling streamed
-	 * transactions, thus no need to consider the possiblity that upcoming
-	 * parallel workers would go ahead.
-	 */
-	if (TransactionIdIsValid(stream_xid) && !found)
-	{
-		free_replica_identity_key(rientry->keydata);
-		replica_identity_delete_item(replica_identity_table, rientry);
-	}
+		oldctx = MemoryContextSwitchTo(ApplyContext);
 
-	/*
-	 * Update the new depended xid into the entry if valid, the new xid could
-	 * be invalid if the transaction will be applied by the leader itself
-	 * which means all the changes will be committed before processing next
-	 * transaction, so no need to be depended on.
-	 */
-	else if (TransactionIdIsValid(new_depended_xid))
-		rientry->remote_xid = new_depended_xid;
+		/* Allocate space for replica identity values */
+		ridata = palloc0_object(LogicalRepTupleData);
+		ridata->colvalues = palloc0_array(StringInfoData, columns);
+		ridata->colstatus = palloc0_array(char, columns);
+		ridata->ncols = columns;
 
-	/*
-	 * Remove the entry if the transaction has been committed and no new
-	 * dependency needs to be added.
-	 */
-	else if (!TransactionIdIsValid(rientry->remote_xid))
-	{
-		free_replica_identity_key(rientry->keydata);
-		replica_identity_delete_item(replica_identity_table, rientry);
+		for (int i_original = 0, i_key = 0; i_original < original_data->ncols; i_original++)
+		{
+			if (!bms_is_member(i_original, idxinfo->indexkeys))
+				continue;
+
+			if (original_data->colstatus[i_original] != LOGICALREP_COLUMN_NULL)
+			{
+				StringInfo	original_colvalue = &original_data->colvalues[i_original];
+
+				initStringInfoExt(&ridata->colvalues[i_key], original_colvalue->len + 1);
+				appendStringInfoString(&ridata->colvalues[i_key], original_colvalue->data);
+			}
+
+			ridata->colstatus[i_key] = original_data->colstatus[i_original];
+			i_key++;
+		}
+
+		rikey = palloc0_object(ReplicaIdentityKey);
+		rikey->relid = relid;
+		rikey->kind = LOGICALREP_KEY_LOCAL_UNIQUE;
+		rikey->data = ridata;
+
+		MemoryContextSwitchTo(oldctx);
+
+		check_dependency_by_key(rikey, new_depended_xid, depends_on_xids);
 	}
 }
 
@@ -1181,6 +1327,9 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			check_dependency_on_replica_identity(relid, &newtup,
 												 new_depended_xid,
 												 &depends_on_xids);
+			check_dependency_on_local_key(relid, &newtup,
+										  new_depended_xid,
+										  &depends_on_xids);
 			check_dependency_for_parallel_safety(relid, new_depended_xid,
 												 &depends_on_xids);
 			break;
@@ -1194,6 +1343,9 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 				check_dependency_on_replica_identity(relid, &oldtup,
 													 new_depended_xid,
 													 &depends_on_xids);
+				check_dependency_on_local_key(relid, &oldtup,
+											  new_depended_xid,
+											  &depends_on_xids);
 				check_dependency_for_parallel_safety(relid, new_depended_xid,
 													 &depends_on_xids);
 			}
@@ -1201,6 +1353,9 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			check_dependency_on_replica_identity(relid, &newtup,
 												 new_depended_xid,
 												 &depends_on_xids);
+			check_dependency_on_local_key(relid, &newtup,
+										  new_depended_xid,
+										  &depends_on_xids);
 			check_dependency_for_parallel_safety(relid, new_depended_xid,
 												 &depends_on_xids);
 			break;
@@ -1210,6 +1365,9 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			check_dependency_on_replica_identity(relid, &oldtup,
 												 new_depended_xid,
 												 &depends_on_xids);
+			check_dependency_on_local_key(relid, &oldtup,
+										  new_depended_xid,
+										  &depends_on_xids);
 			check_dependency_for_parallel_safety(relid, new_depended_xid,
 												 &depends_on_xids);
 			break;
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index 8334a887618..b37d872bfd0 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -33,7 +33,6 @@
 #include "storage/procnumber.h"
 #include "utils/memutils.h"
 
-
 /*
  * One edge in the waits-for graph.
  *
diff --git a/src/include/replication/logicalrelation.h b/src/include/replication/logicalrelation.h
index 916d21c2dbd..9152effb2cd 100644
--- a/src/include/replication/logicalrelation.h
+++ b/src/include/replication/logicalrelation.h
@@ -16,6 +16,12 @@
 #include "catalog/index.h"
 #include "replication/logicalproto.h"
 
+typedef struct LogicalRepSubscriberIdx
+{
+	Oid			indexoid;	/* OID of the local key */
+	Bitmapset  *indexkeys;	/* Bitmap of key columns *on remote* */
+} LogicalRepSubscriberIdx;
+
 typedef struct LogicalRepRelMapEntry
 {
 	LogicalRepRelation remoterel;	/* key is remoterel.remoteid */
@@ -40,6 +46,10 @@ typedef struct LogicalRepRelMapEntry
 
 	TransactionId last_depended_xid;
 
+	/* Local unique indexes. Used for dependency tracking */
+	List	   *local_unique_indexes;
+	bool		local_unique_indexes_collected;
+
 	/*
 	 * Whether the relation can be applied in parallel or not. It is
 	 * distinglish whether defined triggers are the immutable or not.
@@ -51,6 +61,10 @@ typedef struct LogicalRepRelMapEntry
 	 * Note that we do not check the user-defined constraints here. PostgreSQL
 	 * has already assumed that CHECK constraints' conditions are immutable and
 	 * here follows the rule.
+	 *
+	 * XXX: Additonally, this can be false if the relation has expression
+	 * indexes. Because we cannot compute the hash value for the dependency
+	 * tracking.
 	 */
 	char		parallel_safe;
 } LogicalRepRelMapEntry;
diff --git a/src/test/subscription/t/050_parallel_apply.pl b/src/test/subscription/t/050_parallel_apply.pl
index 9254b85d350..9cb395fa4d8 100644
--- a/src/test/subscription/t/050_parallel_apply.pl
+++ b/src/test/subscription/t/050_parallel_apply.pl
@@ -234,4 +234,61 @@ $node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset
 
 $h->query_safe("COMMIT;");
 
+# Ensure subscriber-local indexes are also used for the dependency tracking
+
+# Truncate the data for upcoming tests
+$node_publisher->safe_psql('postgres', "TRUNCATE TABLE regress_tab;");
+$node_publisher->wait_for_catchup('regress_sub');
+
+# Define an unique index on subscriber
+$node_subscriber->safe_psql('postgres',
+    "CREATE INDEX ON regress_tab (value);");
+
+# Attach an injection_point. Parallel workers would wait before the commit
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+# Insert a tuple on publisher. Parallel worker would wait at the injection
+# point
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (1, 'would conflict');");
+
+# Wait until the parallel worker enters the injection point.
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+$offset = -s $node_subscriber->logfile;
+
+# Insert tuples on publisher again. This transaction is would wait because all
+# parallel workers wait till the previously launched worker commits.
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (2, 'would not conflict');");
+
+# Verify the parallel worker waits for the transaction
+$str = $node_subscriber->wait_for_log(qr/wait for depended xid ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /wait for depended xid ([1-9][0-9]+)/;
+
+# Insert a conflicting tuple on publisher. Leader worker would detect the conflict
+# and wait for the transaction to commit.
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (3, 'would conflict');");
+
+# Verify the parallel worker waits for the same transaction
+$node_subscriber->wait_for_log(qr/wait for depended xid $xid/, $offset);
+
+# Wakeup the parallel worker
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+# Verify the streamed transaction can be applied
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+
+# Cleanup
+$node_subscriber->safe_psql('postgres', "DROP INDEX regress_tab_value_idx;");
+$node_publisher->safe_psql('postgres', "TRUNCATE TABLE regress_tab;");
+$node_publisher->wait_for_catchup('regress_sub');
+
 done_testing();
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 811a23ac03c..18eef00e735 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -1642,6 +1642,7 @@ LogicalRepBeginData
 LogicalRepCommitData
 LogicalRepCommitPreparedTxnData
 LogicalRepCtxStruct
+LogicalRepKeyKind
 LogicalRepMsgType
 LogicalRepPartMapEntry
 LogicalRepPreparedTxnData
@@ -1651,6 +1652,7 @@ LogicalRepRelation
 LogicalRepRollbackPreparedTxnData
 LogicalRepSequenceInfo
 LogicalRepStreamAbortData
+LogicalRepSubscriberIdx
 LogicalRepTupleData
 LogicalRepTyp
 LogicalRepWorker
-- 
2.47.3



  [application/octet-stream] v8-0009-Support-dependency-tracking-via-foreign-keys.patch (16.7K, ../../TY7PR01MB1455403538191EE4FA429106FF59FA@TY7PR01MB14554.jpnprd01.prod.outlook.com/10-v8-0009-Support-dependency-tracking-via-foreign-keys.patch)
  download | inline diff:
From e74a96815b0a736bf301f6f1443a7c8d04448724 Mon Sep 17 00:00:00 2001
From: Hayato Kuroda <[email protected]>
Date: Fri, 23 Jan 2026 15:08:15 +0900
Subject: [PATCH v8 9/9] Support dependency tracking via foreign keys

Logical replication's parallel apply mechanism did not account for foreign-key
dependencies. This can cause a failure because the reference column may be
committed before the referenced column.

This patch extends the parallel apply's dependency tracking to account for
foreign keys in the subscriber table. One assumption here is that referenced
columns can be registered to the identity hash table.
The leader apply worker checks to determine whether values in referencing
columns have already been registered or not, and regards that there is a
dependency if found.
---
 src/backend/replication/logical/relation.c    | 185 ++++++++++++++++++
 src/backend/replication/logical/worker.c      | 142 ++++++++++++++
 src/include/replication/logicalrelation.h     |  11 ++
 src/test/subscription/t/050_parallel_apply.pl |  50 ++++-
 4 files changed, 385 insertions(+), 3 deletions(-)

diff --git a/src/backend/replication/logical/relation.c b/src/backend/replication/logical/relation.c
index 6665f5c0cc9..83602dcd4cf 100644
--- a/src/backend/replication/logical/relation.c
+++ b/src/backend/replication/logical/relation.c
@@ -21,6 +21,7 @@
 #include "access/genam.h"
 #include "access/table.h"
 #include "catalog/namespace.h"
+#include "catalog/pg_constraint.h"
 #include "catalog/pg_proc.h"
 #include "catalog/pg_subscription_rel.h"
 #include "commands/trigger.h"
@@ -143,6 +144,21 @@ free_local_unique_indexes(LogicalRepRelMapEntry *entry)
 	entry->local_unique_indexes = NIL;
 }
 
+/*
+ * Release foreign key list
+ */
+static void
+free_local_fkeys(LogicalRepRelMapEntry *entry)
+{
+	Assert(am_leader_apply_worker());
+
+	foreach_ptr(LogicalRepSubscriberFK, fkinfo, entry->local_fkeys)
+		bms_free(fkinfo->conkeys);
+
+	list_free_deep(entry->local_fkeys);
+	entry->local_fkeys = NIL;
+}
+
 /*
  * Free the entry of a relation map cache.
  */
@@ -173,6 +189,9 @@ logicalrep_relmap_free_entry(LogicalRepRelMapEntry *entry)
 
 	if (entry->local_unique_indexes != NIL)
 		free_local_unique_indexes(entry);
+
+	if (entry->local_fkeys != NIL)
+		free_local_fkeys(entry);
 }
 
 /*
@@ -499,6 +518,171 @@ collect_local_indexes(LogicalRepRelMapEntry *entry)
 	list_free(idxlist);
 }
 
+/*
+ * Search a relmap entry by local relation OID.
+ */
+static LogicalRepRelMapEntry *
+logicalrep_get_relentry_by_local_oid(Oid localreloid)
+{
+	HASH_SEQ_STATUS status;
+	LogicalRepRelMapEntry *entry = NULL;
+
+	if (LogicalRepRelMap == NULL)
+		return NULL;
+
+	/*
+	 * Each entry must be checked individually. Because the key of
+	 * LogicalRepRelMap is the "remote" relid but we only have the local one.
+	 *
+	 * Note: This iteration ignores relations that have not yet been
+	 * registered in LogicalRepRelMap. It is OK because such relations have
+	 * not been modified yet since the subscriber started receiving changes.
+	 */
+	hash_seq_init(&status, LogicalRepRelMap);
+	while ((entry = (LogicalRepRelMapEntry *) hash_seq_search(&status)) != NULL)
+	{
+		if (entry->localreloid == localreloid)
+		{
+			hash_seq_term(&status);
+			return entry;
+		}
+	}
+
+	return NULL;
+}
+
+/*
+ * Return true if the FK constraint is always deferred.
+ */
+static bool
+foreign_key_is_always_deferred(Oid conoid)
+{
+	HeapTuple	tup;
+	Form_pg_constraint con;
+	bool		deferred;
+
+	tup = SearchSysCache1(CONSTROID, ObjectIdGetDatum(conoid));
+	if (!HeapTupleIsValid(tup))
+		elog(ERROR, "cache lookup failed for foreign key %u", conoid);
+
+	con = (Form_pg_constraint) GETSTRUCT(tup);
+	deferred = con->condeferrable && con->condeferred;
+	ReleaseSysCache(tup);
+
+	return deferred;
+}
+
+/*
+ * Collect all local foreign keys that can be used for dependency tracking.
+ */
+static void
+collect_local_fkeys(LogicalRepRelMapEntry *entry)
+{
+	List	   *fkeys;
+
+	if (entry->local_fkeys != NIL)
+		free_local_fkeys(entry);
+
+	entry->local_fkeys_collected = true;
+
+	/*
+	 * Get the list of foreign keys for the relation.
+	 *
+	 * XXX: Apart from RelationGetIndexList(), the returned list is a part of
+	 * relcache and must be copied before doing anything. See comments atop
+	 * RelationGetFKeyList().
+	 */
+	fkeys = copyObject(RelationGetFKeyList(entry->localrel));
+
+	/* Quick exit if there are no foreign keys */
+	if (fkeys == NIL)
+		return;
+
+	foreach_ptr(ForeignKeyCacheInfo, fk, fkeys)
+	{
+		LogicalRepRelMapEntry *refentry;
+		Bitmapset  *fkkeys = NULL;
+		bool		suitable = true;
+
+		/*
+		 * Skip NOT ENFORCED constraints because they won't be checked at any
+		 * times.
+		 */
+		if (!fk->conenforced)
+			continue;
+
+		/*
+		 * Skip if the foreign key constraint is always deferred. The commit
+		 * ordering is always preserved thus the constraint can be checked in
+		 * correct order.
+		 */
+		if (foreign_key_is_always_deferred(fk->conoid))
+			continue;
+
+		/* Find the referenced relation by the local OID */
+		refentry = logicalrep_get_relentry_by_local_oid(fk->confrelid);
+
+		/*
+		 * Skip if the referenced relation is not the target of this
+		 * subscription.
+		 */
+		if (!refentry)
+			continue;
+
+		/* Seek each attributes and add to a Bitmap */
+		for (int i = 0; i < fk->nkeys; i++)
+		{
+			AttrNumber	localcol = fk->conkey[i];
+			int			remotecol =
+				entry->attrmap->attnums[AttrNumberGetAttrOffset(localcol)];
+
+			/* Skip if the column does not exist on publisher node */
+			if (remotecol < 0)
+			{
+				suitable = false;
+				break;
+			}
+
+			/*
+			 * XXX: What if the FK column is specified with different order?
+			 * E.g., there is a table (a, b) and other table refers like
+			 * REFERENCES (b, a). Will it work correctly?
+			 */
+			fkkeys = bms_add_member(fkkeys, remotecol);
+		}
+
+		/*
+		 * One of the columns does not exist on the publisher side, skip such
+		 * a constraint.
+		 */
+		if (!suitable)
+		{
+			if (fkkeys)
+				bms_free(fkkeys);
+
+			continue;
+		}
+
+		if (fkkeys)
+		{
+			MemoryContext oldctx;
+			LogicalRepSubscriberFK *fkinfo;
+
+			oldctx = MemoryContextSwitchTo(LogicalRepRelMapContext);
+			fkinfo = palloc(sizeof(LogicalRepSubscriberFK));
+			fkinfo->conoid = fk->conoid;
+			fkinfo->ref_remoteid = refentry->remoterel.remoteid;
+			fkinfo->conkeys = bms_copy(fkkeys);
+
+			bms_free(fkkeys);
+			entry->local_fkeys = lappend(entry->local_fkeys, fkinfo);
+			MemoryContextSwitchTo(oldctx);
+		}
+	}
+
+	list_free_deep(fkeys);
+}
+
 /*
  * Check all local triggers for the relation to see the parallelizability.
  *
@@ -715,6 +899,7 @@ logicalrep_rel_load(LogicalRepRelMapEntry *entry, LogicalRepRelId remoteid,
 		{
 			entry->parallel_safe = LOGICALREP_PARALLEL_UNKNOWN;
 			collect_local_indexes(entry);
+			collect_local_fkeys(entry);
 			check_defined_triggers(entry);
 		}
 
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 6851596625e..e36c6dd801b 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -1148,6 +1148,136 @@ check_dependency_on_local_key(Oid relid,
 	}
 }
 
+/*
+ * Lookup-only dependency check by key. Does not register/update entries.
+ */
+static void
+check_dependency_lookup_by_key(ReplicaIdentityKey *key,
+							   TransactionId new_depended_xid,
+							   List **depends_on_xids)
+{
+	ReplicaIdentityEntry *rientry;
+	MemoryContext oldctx;
+
+	Assert(replica_identity_table);
+
+	oldctx = MemoryContextSwitchTo(ApplyContext);
+
+	rientry = replica_identity_lookup(replica_identity_table, key);
+	free_replica_identity_key(key);
+
+	MemoryContextSwitchTo(oldctx);
+
+	if (!rientry)
+		return;
+
+	/*
+	 * Clean up committed entries while on it.
+	 */
+	if (pa_transaction_committed(rientry->remote_xid))
+	{
+		free_replica_identity_key(rientry->keydata);
+		replica_identity_delete_item(replica_identity_table, rientry);
+		return;
+	}
+
+	*depends_on_xids = check_and_append_xid_dependency(*depends_on_xids,
+													   &rientry->remote_xid,
+													   new_depended_xid);
+}
+
+/*
+ * Check for dependencies on preceding transactions that modify the referenced
+ * unique key of foreign keys.
+ */
+static void
+check_dependency_on_foreign_key(Oid relid,
+								LogicalRepTupleData *original_data,
+								TransactionId new_depended_xid,
+								List **depends_on_xids)
+{
+	LogicalRepRelMapEntry *relentry;
+	LogicalRepTupleData *ridata;
+	ReplicaIdentityKey *rikey;
+	MemoryContext oldctx;
+
+	Assert(depends_on_xids);
+
+	relentry = logicalrep_get_relentry(relid);
+
+	Assert(relentry);
+
+	/*
+	 * We have already checked the local unique indexes and also gathered the
+	 * FK info at that time.
+	 *
+	 * XXX: Should we check and load again?
+	 */
+	Assert(relentry->local_fkeys_collected);
+
+	foreach_ptr(LogicalRepSubscriberFK, fkinfo, relentry->local_fkeys)
+	{
+		int			columns = bms_num_members(fkinfo->conkeys);
+		bool		suitable = true;
+
+		Assert(columns);
+
+		for (int i = 0; i < original_data->ncols; i++)
+		{
+			if (!bms_is_member(i, fkinfo->conkeys))
+				continue;
+
+			/* Skip if the column is NULL or not changed */
+			if (original_data->colstatus[i] == LOGICALREP_COLUMN_NULL ||
+				original_data->colstatus[i] == LOGICALREP_COLUMN_UNCHANGED)
+			{
+				suitable = false;
+				break;
+			}
+		}
+
+		if (!suitable)
+			continue;
+
+		oldctx = MemoryContextSwitchTo(ApplyContext);
+
+		/* Allocate space for replica identity values */
+		ridata = palloc0_object(LogicalRepTupleData);
+		ridata->colvalues = palloc0_array(StringInfoData, columns);
+		ridata->colstatus = palloc0_array(char, columns);
+		ridata->ncols = columns;
+
+		for (int i_original = 0, i_key = 0; i_original < original_data->ncols; i_original++)
+		{
+			StringInfo	original_colvalue;
+
+			if (!bms_is_member(i_original, fkinfo->conkeys))
+				continue;
+
+			original_colvalue = &original_data->colvalues[i_original];
+			initStringInfoExt(&ridata->colvalues[i_key], original_colvalue->len + 1);
+			appendStringInfoString(&ridata->colvalues[i_key], original_colvalue->data);
+
+			ridata->colstatus[i_key] = original_data->colstatus[i_original];
+			i_key++;
+		}
+
+		rikey = palloc0_object(ReplicaIdentityKey);
+		rikey->relid = fkinfo->ref_remoteid;
+
+		/*
+		 * XXX: use LOGICALREP_KEY_LOCAL_UNIQUE to compare with local unique
+		 * indexes.
+		 */
+		rikey->kind = LOGICALREP_KEY_LOCAL_UNIQUE;
+		rikey->data = ridata;
+
+		MemoryContextSwitchTo(oldctx);
+
+		check_dependency_lookup_by_key(rikey, new_depended_xid, depends_on_xids);
+	}
+}
+
 /*
  * Check for preceding transactions that involve insert, delete, or update
  * operations on the specified table, and return them in 'depends_on_xids'.
@@ -1330,6 +1460,9 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			check_dependency_on_local_key(relid, &newtup,
 										  new_depended_xid,
 										  &depends_on_xids);
+			check_dependency_on_foreign_key(relid, &newtup,
+											new_depended_xid,
+											&depends_on_xids);
 			check_dependency_for_parallel_safety(relid, new_depended_xid,
 												 &depends_on_xids);
 			break;
@@ -1346,6 +1479,9 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 				check_dependency_on_local_key(relid, &oldtup,
 											  new_depended_xid,
 											  &depends_on_xids);
+				check_dependency_on_foreign_key(relid, &oldtup,
+												new_depended_xid,
+												&depends_on_xids);
 				check_dependency_for_parallel_safety(relid, new_depended_xid,
 													 &depends_on_xids);
 			}
@@ -1356,6 +1492,9 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			check_dependency_on_local_key(relid, &newtup,
 										  new_depended_xid,
 										  &depends_on_xids);
+			check_dependency_on_foreign_key(relid, &newtup,
+											new_depended_xid,
+											&depends_on_xids);
 			check_dependency_for_parallel_safety(relid, new_depended_xid,
 												 &depends_on_xids);
 			break;
@@ -1368,6 +1507,9 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			check_dependency_on_local_key(relid, &oldtup,
 										  new_depended_xid,
 										  &depends_on_xids);
+			check_dependency_on_foreign_key(relid, &oldtup,
+											new_depended_xid,
+											&depends_on_xids);
 			check_dependency_for_parallel_safety(relid, new_depended_xid,
 												 &depends_on_xids);
 			break;
diff --git a/src/include/replication/logicalrelation.h b/src/include/replication/logicalrelation.h
index 9152effb2cd..e915fb53246 100644
--- a/src/include/replication/logicalrelation.h
+++ b/src/include/replication/logicalrelation.h
@@ -22,6 +22,13 @@ typedef struct LogicalRepSubscriberIdx
 	Bitmapset  *indexkeys;	/* Bitmap of key columns *on remote* */
 } LogicalRepSubscriberIdx;
 
+typedef struct LogicalRepSubscriberFK
+{
+	Oid			conoid;		/* OID of the FK constraint */
+	LogicalRepRelId ref_remoteid; /* referenced remote relid */
+	Bitmapset  *conkeys;	/* Bitmap of key columns *on remote* */
+} LogicalRepSubscriberFK;
+
 typedef struct LogicalRepRelMapEntry
 {
 	LogicalRepRelation remoterel;	/* key is remoterel.remoteid */
@@ -50,6 +57,10 @@ typedef struct LogicalRepRelMapEntry
 	List	   *local_unique_indexes;
 	bool		local_unique_indexes_collected;
 
+	/* Local foreign keys. Used for dependency tracking */
+	List	   *local_fkeys;
+	bool		local_fkeys_collected;
+
 	/*
 	 * Whether the relation can be applied in parallel or not. It is
 	 * distinglish whether defined triggers are the immutable or not.
diff --git a/src/test/subscription/t/050_parallel_apply.pl b/src/test/subscription/t/050_parallel_apply.pl
index 9cb395fa4d8..54352a5f9b9 100644
--- a/src/test/subscription/t/050_parallel_apply.pl
+++ b/src/test/subscription/t/050_parallel_apply.pl
@@ -26,6 +26,8 @@ $node_publisher->safe_psql('postgres',
     "CREATE TABLE regress_tab (id int PRIMARY KEY, value text);");
 $node_publisher->safe_psql('postgres',
     "INSERT INTO regress_tab VALUES (generate_series(1, 10), 'test');");
+$node_publisher->safe_psql('postgres',
+    "CREATE TABLE regress_tab_fk (id int PRIMARY KEY, fk int);");
 
 # Create a publication
 $node_publisher->safe_psql('postgres',
@@ -56,6 +58,8 @@ my $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
 
 $node_subscriber->safe_psql('postgres',
     "CREATE TABLE regress_tab (id int PRIMARY KEY, value text);");
+$node_subscriber->safe_psql('postgres',
+    "CREATE TABLE regress_tab_fk (id int PRIMARY KEY, fk int REFERENCES regress_tab (id));");
 $node_subscriber->safe_psql('postgres',
     "CREATE SUBSCRIPTION regress_sub CONNECTION '$publisher_connstr' PUBLICATION regress_pub;");
 
@@ -236,8 +240,8 @@ $h->query_safe("COMMIT;");
 
 # Ensure subscriber-local indexes are also used for the dependency tracking
 
-# Truncate the data for upcoming tests
-$node_publisher->safe_psql('postgres', "TRUNCATE TABLE regress_tab;");
+# delete the data for upcoming tests
+$node_publisher->safe_psql('postgres', "DELETE FROM regress_tab;");
 $node_publisher->wait_for_catchup('regress_sub');
 
 # Define an unique index on subscriber
@@ -288,7 +292,47 @@ $node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset
 
 # Cleanup
 $node_subscriber->safe_psql('postgres', "DROP INDEX regress_tab_value_idx;");
-$node_publisher->safe_psql('postgres', "TRUNCATE TABLE regress_tab;");
+$node_publisher->safe_psql('postgres', "DELETE FROM regress_tab;");
 $node_publisher->wait_for_catchup('regress_sub');
 
+# Ensure subscriber-local indexes are also used for the dependency tracking
+
+# Insert an initial tuple
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (1, 'initial tuple');");
+$node_publisher->wait_for_catchup('regress_sub');
+
+# Attach an injection_point. Parallel workers would wait before the commit
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+# Insert a tuple on publisher. Parallel worker would wait at the injection
+# point
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (2, 'tmp');");
+
+# Wait until the parallel worker enters the injection point.
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+$offset = -s $node_subscriber->logfile;
+
+# Insert tuples on publisher again. This transaction waits for referring tuple
+# is committed.
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab_fk VALUES (1, 1);");
+
+# Verify the parallel worker waits for the transaction
+$str = $node_subscriber->wait_for_log(qr/wait for depended xid ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /wait for depended xid ([1-9][0-9]+)/;
+
+# Wakeup the parallel worker
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+
 done_testing();
-- 
2.47.3



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

* RE: Parallel Apply
@ 2026-06-04 06:58 Zhijie Hou (Fujitsu) <[email protected]>
  2026-06-09 04:14 ` Re: Parallel Apply Peter Smith <[email protected]>
  0 siblings, 1 reply; 63+ messages in thread

From: Zhijie Hou (Fujitsu) @ 2026-06-04 06:58 UTC (permalink / raw)
  To: shveta malik <[email protected]>; +Cc: Hayato Kuroda (Fujitsu) <[email protected]>; PostgreSQL Hackers <[email protected]>; Tomas Vondra <[email protected]>; Dilip Kumar <[email protected]>; Andrei Lepikhov <[email protected]>; wenhui qiu <[email protected]>; Amit Kapila <[email protected]>

On Wednesday, May 13, 2026 5:32 PM shveta malik <[email protected]> wrote:
> 
> On Wed, May 13, 2026 at 10:54 AM shveta malik <[email protected]>
> wrote:
> >
> >
> > Please find a few comments for patch003 mostly:

Thanks for the comments!

> >
> > 1)
> >   * depended on by other transactions. Entries are of type
> ParallelizedTxnEntry.
> >   *
> >   * dshash is used to enable dynamic shared memory allocation based on
> > the number
> > - * of transactions being applied in parallel.
> > + * of transactions being applied in parallel. Entries are of type
> > ParallelizedTxnEntry.
> >   */
> >  static dsa_area *parallel_apply_dsa_area = NULL;  static dshash_table
> > *parallelized_txns = NULL;
> >
> > 'Entries are of type ParallelizedTxnEntry' repeated twice in this comment.

Removed.

> >
> >
> > 2)
> > cleanup_committed_replica_identity_entries:
> >
> > + if (!skipped_write && !XLogRecPtrIsValid(pos->local_end))
> > + continue;
> >
> > Perhaps a comment will help to indicate above checks means a txn not
> > yet finished.

Added.

> >
> > 3)
> > Can you please clarify the scope, life-span of entries in
> > parallelized_txns vs ParallelApplyTxnHash. Both have remote-xid field.
> > So at any point of time, do both tables will have same number of
> > entries or if entries in one has bigger life-span/scope as compared to
> > other? It will be good to briefly mention these atop the hash-tables.


Added comments for it atop of these hash tables.

> >
> > 4)
> > +/*
> > + * Hash table storing replica identity values for changes being
> > +applied in
> > + * parallel, along with the last transaction that modified each row.
> > ...
> > +static replica_identity_hash *replica_identity_table = NULL;
> >
> > Regarding 'along with the last transaction that modified each row', is
> > 'remote_xid' in ReplicaIdentityEntry is the last transaction that
> > modified this row or the one which is currently modifying it?

It could be either, depending on whether the transaction is still running or
has already finished. In this version, I've changed it to be the "latest"
transaction.

> >
> > 5)
> > Since we have added comments for rest for the fields for below
> > existing structure, do you think we can update comment for 'xid' as
> > well to say it is remote-one. It does not mention it anywhere in
> > comment.
> >
> > typedef struct ParallelApplyWorkerEntry { TransactionId xid; /* Hash
> > key -- must be first */
> >

Added.

> >
> > 6)
> > 003' commit message says about RI table entry:
> >
> > Entries are deleted when transactions committed by parallel workers
> > are gathered, or the number of entries exceeds the limit.
> > --
> > I don't understand what do we mean by "when transactions committed by
> > parallel workers are gathered". Can we please make it more
> > clear/elaborate.
> >

I modified this to be consistent with some detailed comments in the patch.

> > ~~
> >
> > Reviewing further.
> >
> 
> Few more on 003:
> 
> 
> 7)
> I find check_and_record_ri_dependency() difficult to understand.
> 
> a)
> As an example, this part:
> 
> + /*
> + * Return if no entry exists, or if the current transaction was the
> + last one
> + * to modify the key.
> + */
> + if (!rientry || TransactionIdEquals(rientry->remote_xid,
> + new_depended_xid)) return;
> 
> IMO, the second check makes sense if new_depended_xid is valid. If so, why
> don't we make it part of previous 'if
> (TransactionIdIsValid(new_depended_xid))' logic. If 'found' was true, we can
> check it inside that if-block and 'return' from there instead of proceeding
> further.
> 
> Once we make above change, we can even move below logic inside previous
> 'if (TransactionIdIsValid(new_depended_xid))' block, as it looks strange that in
> previous if-block we are assigning InvalidTransactionId to 'rientry-
> >remote_xid' while we have valid new_depended_xid available there.
> 
> + /*
> + * Update the new depended xid into the entry if valid, the new xid
> + could
> + * be invalid if the transaction will be applied by the leader itself
> + * which means all the changes will be committed before processing next
> + * transaction, so no need to be depended on.
> + */
> + if (TransactionIdIsValid(new_depended_xid))
> + rientry->remote_xid = new_depended_xid;

Right. The original code was written that way to save a few lines,
but I agree it's harder to understand. I've refactored this part to
make each section clearer.

> 
> 
> b)
> Also this part is not clear:
> 
> + /*
> + * Return if RI key is NULL or is explicitly marked unchanged. The key
> + * value could be NULL in the new tuple of a update opertaion which
> + * means the RI key is not updated.
> + */
> + if (original_data->colstatus[i] == LOGICALREP_COLUMN_NULL ||
> + original_data->colstatus[i] == LOGICALREP_COLUMN_UNCHANGED)
> return;
> 
> Why do have we 'return' here when one of the columns is NULL or
> unchanged? What happens to rest of the RI columns? Which scenario may hit
> this? It needs more comments to explain the scenario.

After testing, I found that the handling for unchanged toasted columns was
incorrect. When the RI key includes two columns and only the non-toasted column
is changed, the toasted column is not included in the new tuple of an UPDATE
change. This means we cannot obtain the complete RI key value to check for
dependencies when handling the new change. I fixed this by copying the toasted
value from the old tuple to the new one before dependency checking.

> 
> 
> 8)
> check_and_record_ri_dependency() has this comment and logic around
> invalid remote_xid:
> 
> + /*
> + * Remove the entry if the transaction has been committed and no new
> + * dependency needs to be added.
> + */
> + else if (!TransactionIdIsValid(rientry->remote_xid))
> + {
> + free_replica_identity_key(rientry->keydata);
> + replica_identity_delete_item(replica_identity_table, rientry); }
> 
> While find_all_dependencies_on_rel() has this assert:
> + Assert(TransactionIdIsValid(rientry->remote_xid));
> 
> The first logic says that we may have Invalid remote_xid in existing entry in
> replica_identity_table while second logic has a sanity check while iterating
> the same hash-table that all entries must have valid remote_xid. Is the Assert
> correct? We might have Invalid remote_xid if txn is committed (done in
> check_and_append_xid_dependency).

It's correct. The remote_xid could be invalid only before being stored
in the table. The check_and_append_xid_dependency function wasn't very clear
about this, but after refactoring, I hope it's easier to understand now.

Here is the V19 patch set which addressed all the comments above.

Best Regards,
Hou zj


Attachments:

  [application/octet-stream] v19-0001-Introduce-a-shared-hash-table-to-store-paralleli.patch (9.2K, ../../TY4PR01MB17718A4AB7F564C8DB0D6F7DC94102@TY4PR01MB17718.jpnprd01.prod.outlook.com/2-v19-0001-Introduce-a-shared-hash-table-to-store-paralleli.patch)
  download | inline diff:
From 5a201d629e9520e0f3ad1f28155589c62227addc Mon Sep 17 00:00:00 2001
From: Zhijie Hou <[email protected]>
Date: Wed, 27 May 2026 11:31:35 +0800
Subject: [PATCH v19 1/3] Introduce a shared hash table to store parallelized
 transactions

This commit introduces a shared hash table for tracking parallelized
transactions. The hash table uses transaction IDs as keys; no values are stored.

The hash entries are inserted with a remote XID when the leader bypasses remote
transactions to parallel apply workers. Entries are deleted when parallel
workers are committed to corresponding transactions.

Any worker can use this hash table to check whether a transaction is still
in progress:
- If an entry exists, the transaction is still in progress.
- If no entry exists, the transaction has committed.

Later patches will use this hash table to support dependency waiting and
commit order preservation. When transaction A depends on transaction B
(or must commit after B), the worker will wait for transaction B's entry
to be removed before committing transaction A.

Author: Zhijie Hou <[email protected]>
Author: Hayato Kuroda <[email protected]>
---
 .../replication/logical/applyparallelworker.c | 90 ++++++++++++++++++-
 .../utils/activity/wait_event_names.txt       |  1 +
 src/include/replication/worker_internal.h     |  9 ++
 src/include/storage/lwlocklist.h              |  1 +
 src/tools/pgindent/typedefs.list              |  1 +
 5 files changed, 101 insertions(+), 1 deletion(-)

diff --git a/src/backend/replication/logical/applyparallelworker.c b/src/backend/replication/logical/applyparallelworker.c
index 012d55e9d3d..109d85c7cdf 100644
--- a/src/backend/replication/logical/applyparallelworker.c
+++ b/src/backend/replication/logical/applyparallelworker.c
@@ -221,12 +221,38 @@ typedef struct ParallelApplyWorkerEntry
 	ParallelApplyWorkerInfo *winfo;
 } ParallelApplyWorkerEntry;
 
+/* An entry in the parallelized_txns shared hash table */
+typedef struct ParallelizedTxnEntry
+{
+	TransactionId xid;			/* Hash key, remote transaction ID */
+} ParallelizedTxnEntry;
+
 /*
  * A hash table used to cache the state of streaming transactions being applied
- * by the parallel apply workers.
+ * by the parallel apply workers. Entries are of type ParallelApplyWorkerEntry.
  */
 static HTAB *ParallelApplyTxnHash = NULL;
 
+/*
+ * A hash table used to track the parallelized remote transactions that could be
+ * depended on by other transactions. Entries are of type ParallelizedTxnEntry.
+ *
+ * dshash is used to enable dynamic shared memory allocation based on the number
+ * of transactions being applied in parallel.
+ */
+static dsa_area *parallel_apply_dsa_area = NULL;
+static dshash_table *parallelized_txns = NULL;
+
+/* parameters for the parallelized_txns shared hash table */
+static const dshash_parameters dsh_params = {
+	sizeof(TransactionId),
+	sizeof(ParallelizedTxnEntry),
+	dshash_memcmp,
+	dshash_memhash,
+	dshash_memcpy,
+	LWTRANCHE_PARALLEL_APPLY_DSA
+};
+
 /*
  * A list (pool) of active parallel apply workers. The information for
  * the new worker is added to the list after successfully launching it. The
@@ -260,6 +286,8 @@ static List *subxactlist = NIL;
 static void pa_free_worker_info(ParallelApplyWorkerInfo *winfo);
 static ParallelTransState pa_get_xact_state(ParallelApplyWorkerShared *wshared);
 static PartialFileSetState pa_get_fileset_state(void);
+static void pa_attach_parallelized_txn_hash(dsa_handle *pa_dsa_handle,
+											dshash_table_handle *pa_dshash_handle);
 
 /*
  * Returns true if it is OK to start a parallel apply worker, false otherwise.
@@ -337,6 +365,15 @@ pa_setup_dsm(ParallelApplyWorkerInfo *winfo)
 	shm_mq	   *mq;
 	Size		queue_size = DSM_QUEUE_SIZE;
 	Size		error_queue_size = DSM_ERROR_QUEUE_SIZE;
+	dsa_handle	parallel_apply_dsa_handle;
+	dshash_table_handle parallelized_txns_handle;
+
+	pa_attach_parallelized_txn_hash(&parallel_apply_dsa_handle,
+									&parallelized_txns_handle);
+
+	if (parallel_apply_dsa_handle == DSA_HANDLE_INVALID ||
+		parallelized_txns_handle == DSHASH_HANDLE_INVALID)
+		return false;
 
 	/*
 	 * Estimate how much shared memory we need.
@@ -372,6 +409,8 @@ pa_setup_dsm(ParallelApplyWorkerInfo *winfo)
 	pg_atomic_init_u32(&(shared->pending_stream_count), 0);
 	shared->last_commit_end = InvalidXLogRecPtr;
 	shared->fileset_state = FS_EMPTY;
+	shared->parallel_apply_dsa_handle = parallel_apply_dsa_handle;
+	shared->parallelized_txns_handle = parallelized_txns_handle;
 
 	shm_toc_insert(toc, PARALLEL_APPLY_KEY_SHARED, shared);
 
@@ -876,6 +915,8 @@ ParallelApplyWorkerMain(Datum main_arg)
 	shm_mq	   *mq;
 	shm_mq_handle *mqh;
 	shm_mq_handle *error_mqh;
+	dsa_handle	pa_dsa_handle;
+	dshash_table_handle pa_dshash_handle;
 	ReplOriginId originid;
 	int			worker_slot = DatumGetInt32(main_arg);
 	char		originname[NAMEDATALEN];
@@ -962,6 +1003,8 @@ ParallelApplyWorkerMain(Datum main_arg)
 
 	InitializingApplyWorker = false;
 
+	pa_attach_parallelized_txn_hash(&pa_dsa_handle, &pa_dshash_handle);
+
 	/* Setup replication origin tracking. */
 	StartTransactionCommand();
 	ReplicationOriginNameForLogicalRep(MySubscription->oid, InvalidOid,
@@ -1656,3 +1699,48 @@ pa_xact_finish(ParallelApplyWorkerInfo *winfo, XLogRecPtr remote_lsn)
 
 	pa_free_worker(winfo);
 }
+
+/*
+ * Attach to the shared hash table for parallelized transactions.
+ */
+static void
+pa_attach_parallelized_txn_hash(dsa_handle *pa_dsa_handle,
+								dshash_table_handle *pa_dshash_handle)
+{
+	MemoryContext oldctx;
+
+	if (parallelized_txns)
+	{
+		Assert(parallel_apply_dsa_area);
+		*pa_dsa_handle = dsa_get_handle(parallel_apply_dsa_area);
+		*pa_dshash_handle = dshash_get_hash_table_handle(parallelized_txns);
+		return;
+	}
+
+	/* Be sure any local memory allocated by DSA routines is persistent. */
+	oldctx = MemoryContextSwitchTo(ApplyContext);
+
+	if (am_leader_apply_worker())
+	{
+		/* Initialize dynamic shared hash table for parallelized transactions */
+		parallel_apply_dsa_area = dsa_create(LWTRANCHE_PARALLEL_APPLY_DSA);
+		dsa_pin(parallel_apply_dsa_area);
+		dsa_pin_mapping(parallel_apply_dsa_area);
+		parallelized_txns = dshash_create(parallel_apply_dsa_area, &dsh_params, NULL);
+
+		/* Store handles in shared memory for other backends to use. */
+		*pa_dsa_handle = dsa_get_handle(parallel_apply_dsa_area);
+		*pa_dshash_handle = dshash_get_hash_table_handle(parallelized_txns);
+	}
+	else if (am_parallel_apply_worker())
+	{
+		/* Attach to existing dynamic shared hash table. */
+		parallel_apply_dsa_area = dsa_attach(MyParallelShared->parallel_apply_dsa_handle);
+		dsa_pin_mapping(parallel_apply_dsa_area);
+		parallelized_txns = dshash_attach(parallel_apply_dsa_area, &dsh_params,
+										  MyParallelShared->parallelized_txns_handle,
+										  NULL);
+	}
+
+	MemoryContextSwitchTo(oldctx);
+}
diff --git a/src/backend/utils/activity/wait_event_names.txt b/src/backend/utils/activity/wait_event_names.txt
index 560659f9568..1339c33262f 100644
--- a/src/backend/utils/activity/wait_event_names.txt
+++ b/src/backend/utils/activity/wait_event_names.txt
@@ -417,6 +417,7 @@ XactSLRU	"Waiting to access the transaction status SLRU cache."
 ParallelVacuumDSA	"Waiting for parallel vacuum dynamic shared memory allocation."
 AioUringCompletion	"Waiting for another process to complete IO via io_uring."
 ShmemIndex	"Waiting to find or allocate space in shared memory."
+ParallelApplyDSA	"Waiting for parallel apply dynamic shared memory allocation."
 
 # No "ABI_compatibility" region here as WaitEventLWLock has its own C code.
 
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index 745b7d9e969..e60e0c3d6d7 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -15,6 +15,7 @@
 #include "access/xlogdefs.h"
 #include "catalog/pg_subscription.h"
 #include "datatype/timestamp.h"
+#include "lib/dshash.h"
 #include "miscadmin.h"
 #include "replication/logicalrelation.h"
 #include "replication/walreceiver.h"
@@ -196,6 +197,14 @@ typedef struct ParallelApplyWorkerShared
 	 */
 	PartialFileSetState fileset_state;
 	FileSet		fileset;
+
+	/*
+	 * DSA handle for parallel apply workers, along with the handle for the
+	 * shared hash table allocated under it. The hash table stores parallelized
+	 * transaction information.
+	 */
+	dsa_handle	parallel_apply_dsa_handle;
+	dshash_table_handle parallelized_txns_handle;
 } ParallelApplyWorkerShared;
 
 /*
diff --git a/src/include/storage/lwlocklist.h b/src/include/storage/lwlocklist.h
index d7eb648bd27..c5f2921b703 100644
--- a/src/include/storage/lwlocklist.h
+++ b/src/include/storage/lwlocklist.h
@@ -140,3 +140,4 @@ PG_LWLOCKTRANCHE(XACT_SLRU, XactSLRU)
 PG_LWLOCKTRANCHE(PARALLEL_VACUUM_DSA, ParallelVacuumDSA)
 PG_LWLOCKTRANCHE(AIO_URING_COMPLETION, AioUringCompletion)
 PG_LWLOCKTRANCHE(SHMEM_INDEX, ShmemIndex)
+PG_LWLOCKTRANCHE(PARALLEL_APPLY_DSA, ParallelApplyDSA)
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 8cf40c87043..fabda17d739 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2162,6 +2162,7 @@ ParallelHashJoinBatch
 ParallelHashJoinBatchAccessor
 ParallelHashJoinState
 ParallelIndexScanDesc
+ParallelizedTxnEntry
 ParallelSlot
 ParallelSlotArray
 ParallelSlotResultHandler
-- 
2.43.0



  [application/octet-stream] v19-0010-Support-serializing-changes-to-disk-when-the-sen.patch (16.3K, ../../TY4PR01MB17718A4AB7F564C8DB0D6F7DC94102@TY4PR01MB17718.jpnprd01.prod.outlook.com/3-v19-0010-Support-serializing-changes-to-disk-when-the-sen.patch)
  download | inline diff:
From 16ec166f61eddfdeb142af40a9cbc0938adcbe25 Mon Sep 17 00:00:00 2001
From: Zhijie Hou <[email protected]>
Date: Thu, 30 Apr 2026 16:52:41 +0800
Subject: [PATCH v19 10/10] Support serializing changes to disk when the send
 buffer is full

This commit adds support for the leader apply worker to serialize changes to
disk when the send buffer to parallel apply workers becomes full. If a parallel
apply worker cannot send changes to the leader due to a send timeout, it
switches to serialization mode and writes the remaining changes of the remote
transaction to a file. The parallel apply worker later reads and applies the
serialized changes once it has caught up.
---
 .../replication/logical/applyparallelworker.c |  52 ++++-
 src/backend/replication/logical/worker.c      | 184 +++++++++++++-----
 src/test/subscription/t/050_parallel_apply.pl |  30 +++
 3 files changed, 209 insertions(+), 57 deletions(-)

diff --git a/src/backend/replication/logical/applyparallelworker.c b/src/backend/replication/logical/applyparallelworker.c
index 7ced261445c..17cd52394f6 100644
--- a/src/backend/replication/logical/applyparallelworker.c
+++ b/src/backend/replication/logical/applyparallelworker.c
@@ -976,6 +976,38 @@ pa_get_last_commit_end(TransactionId xid, bool delete_entry, bool *skipped_write
 	return entry->local_end;
 }
 
+/*
+ * Wait for the remote transaction associated with the specified remote xid to
+ * complete.
+ */
+static void
+pa_wait_for_transaction(TransactionId wait_for_xid)
+{
+	if (!am_leader_apply_worker())
+		return;
+
+	if (!TransactionIdIsValid(wait_for_xid))
+		return;
+
+	elog(DEBUG1, "plan to wait for remote_xid %u to finish",
+		 wait_for_xid);
+
+	for (;;)
+	{
+		if (pa_transaction_committed(wait_for_xid))
+			break;
+
+		pa_lock_transaction(wait_for_xid, AccessShareLock);
+		pa_unlock_transaction(wait_for_xid, AccessShareLock);
+
+		/* An interrupt may have occurred while we were waiting. */
+		CHECK_FOR_INTERRUPTS();
+	}
+
+	elog(DEBUG1, "finished wait for remote_xid %u to finish",
+		 wait_for_xid);
+}
+
 /*
  * Interrupt handler for main loop of parallel apply worker.
  */
@@ -1595,6 +1627,7 @@ pa_send_data(ParallelApplyWorkerInfo *winfo, Size nbytes, const void *data)
 void
 pa_distribute_schema_changes_to_workers(LogicalRepRelation *rel)
 {
+	List	   *workers_stopped = NIL;
 	StringInfoData out;
 
 	/* Only leader apply workers can distribute schema changes */
@@ -1631,13 +1664,22 @@ pa_distribute_schema_changes_to_workers(LogicalRepRelation *rel)
 			continue;
 
 		/*
-		 * TODO: Support switching to PARTIAL_SERIALIZE mode when the send
-		 * buffer becomes full.
+		 * Distribution to this worker failed due to a sending timeout. Wait
+		 * for the worker to complete its transaction and then stop it. This
+		 * is consistent with the handling of workers in serialize mode (see
+		 * pa_free_worker() for details).
 		 */
-		ereport(ERROR,
-				errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
-				errmsg("could not send data to the logical replication parallel apply worker for subscription"));
+		pa_wait_for_transaction(winfo->shared->xid);
+
+		pa_get_last_commit_end(winfo->shared->xid, false, NULL);
+
+		logicalrep_pa_worker_stop(winfo);
+
+		workers_stopped = lappend(workers_stopped, winfo);
 	}
+
+	foreach_ptr(ParallelApplyWorkerInfo, winfo, workers_stopped)
+		pa_free_worker_info(winfo);
 }
 
 /*
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 6f8d83c6b7b..40ad922b9fc 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -733,9 +733,9 @@ static void set_wal_receiver_timeout(void);
 
 static void on_exit_clear_xact_state(int code, Datum arg);
 
-static void send_internal_dependencies(ParallelApplyWorkerInfo *winfo,
+static bool send_internal_dependencies(ParallelApplyWorkerInfo *winfo,
 									   List *depends_on_xids);
-static void build_dependency_with_last_committed_txn(ParallelApplyWorkerInfo *winfo);
+static bool build_dependency_with_last_committed_txn(ParallelApplyWorkerInfo *winfo);
 
 /*
  * Compute the hash value for entries in the replica_identity_table.
@@ -1931,6 +1931,9 @@ handle_parallelized_transaction(LogicalRepMsgType action, StringInfo s)
 	ParallelApplyWorkerInfo *winfo;
 	TransApplyAction apply_action;
 
+	/* Save the message before it is consumed. */
+	StringInfoData original_msg = *s;
+
 	/*
 	 * Dependency checking for non-streaming transactions is only required in
 	 * the leader apply worker during a remote transaction.
@@ -1951,6 +1954,12 @@ handle_parallelized_transaction(LogicalRepMsgType action, StringInfo s)
 
 	handle_dependency_on_change(action, s, remote_xid, winfo);
 
+	/*
+	 * Re-fetch the latest apply action as it might have been changed during
+	 * dependency check.
+	 */
+	apply_action = get_transaction_apply_action(remote_xid, &winfo);
+
 	switch (apply_action)
 	{
 		case TRANS_LEADER_SEND_TO_PARALLEL:
@@ -1962,13 +1971,18 @@ handle_parallelized_transaction(LogicalRepMsgType action, StringInfo s)
 						action != LOGICAL_REP_MSG_TYPE);
 
 			/*
-			 * TODO: Support switching to PARTIAL_SERIALIZE mode when the send
-			 * buffer becomes full.
+			 * Switch to serialize mode when we are not able to send the
+			 * change to parallel apply worker.
 			 */
-			ereport(ERROR,
-					errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
-					errmsg("could not send data to the logical replication parallel apply worker"));
-			return false;	/* silence compiler warning */
+			pa_switch_to_partial_serialize(winfo, false);
+
+			pg_fallthrough;
+		case TRANS_LEADER_PARTIAL_SERIALIZE:
+			stream_write_change(action, &original_msg);
+
+			/* Same reason as TRANS_LEADER_SEND_TO_PARALLEL case. */
+			return (action != LOGICAL_REP_MSG_RELATION &&
+					action != LOGICAL_REP_MSG_TYPE);
 
 		default:
 			elog(ERROR, "unexpected apply action: %d", (int) apply_action);
@@ -2340,6 +2354,9 @@ apply_handle_begin(StringInfo s)
 	ParallelApplyWorkerInfo *winfo;
 	TransApplyAction apply_action;
 
+	/* Save the message before it is consumed. */
+	StringInfoData original_msg = *s;
+
 	/* There must not be an active streaming transaction. */
 	Assert(!TransactionIdIsValid(stream_xid));
 
@@ -2373,12 +2390,19 @@ apply_handle_begin(StringInfo s)
 			}
 
 			/*
-			 * TODO: Support switching to PARTIAL_SERIALIZE mode when the send
-			 * buffer becomes full.
+			 * Switch to serialize mode when we are not able to send the
+			 * change to parallel apply worker.
 			 */
-			ereport(ERROR,
-					errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
-					errmsg("could not send data to the logical replication parallel apply worker"));
+			pa_switch_to_partial_serialize(winfo, true);
+
+			pg_fallthrough;
+		case TRANS_LEADER_PARTIAL_SERIALIZE:
+			Assert(winfo);
+
+			stream_write_change(LOGICAL_REP_MSG_BEGIN, &original_msg);
+
+			/* Cache the parallel apply worker for this transaction. */
+			pa_set_stream_apply_worker(winfo);
 			break;
 
 		case TRANS_PARALLEL_APPLY:
@@ -2400,7 +2424,7 @@ apply_handle_begin(StringInfo s)
 /*
  * Send an INTERNAL_DEPENDENCY message to a parallel apply worker.
  */
-static void
+static bool
 send_internal_dependencies(ParallelApplyWorkerInfo *winfo, List *depends_on_xids)
 {
 	StringInfoData dependencies;
@@ -2414,14 +2438,22 @@ send_internal_dependencies(ParallelApplyWorkerInfo *winfo, List *depends_on_xids
 	foreach_xid(xid, depends_on_xids)
 		pq_sendint32(&dependencies, xid);
 
+	if (!winfo->serialize_changes)
+	{
+		if (pa_send_data(winfo, dependencies.len, dependencies.data))
+			return true;
+
+		pa_switch_to_partial_serialize(winfo, true);
+	}
+
 	/*
-	 * TODO: Support switching to PARTIAL_SERIALIZE mode when the send
-	 * buffer becomes full.
+	 * Skip writing the first internal message flag because
+	 * stream_write_change() accepts it as the argument.
 	 */
-	if (!pa_send_data(winfo, dependencies.len, dependencies.data))
-		ereport(ERROR,
-				errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
-				errmsg("could not send data to the logical replication parallel apply worker"));
+	dependencies.cursor++;
+	stream_write_change(LOGICAL_REP_MSG_INTERNAL_MESSAGE, &dependencies);
+
+	return false;
 }
 
 /*
@@ -2431,14 +2463,14 @@ send_internal_dependencies(ParallelApplyWorkerInfo *winfo, List *depends_on_xids
  * instructing it to wait for the last committed transaction to finish before
  * committing its own, thereby preserving commit order.
  */
-static void
+static bool
 build_dependency_with_last_committed_txn(ParallelApplyWorkerInfo *winfo)
 {
 	/* Skip if transactions have not been applied yet */
 	if (!TransactionIdIsValid(last_parallelized_remote_xid))
-		return;
+		return true;
 
-	send_internal_dependencies(winfo, list_make1_xid(last_parallelized_remote_xid));
+	return send_internal_dependencies(winfo, list_make1_xid(last_parallelized_remote_xid));
 }
 
 /*
@@ -2453,6 +2485,9 @@ apply_handle_commit(StringInfo s)
 	ParallelApplyWorkerInfo *winfo;
 	TransApplyAction apply_action;
 
+	/* Save the message before it is consumed. */
+	StringInfoData original_msg = *s;
+
 	logicalrep_read_commit(s, &commit_data);
 
 	if (commit_data.commit_lsn != remote_final_lsn)
@@ -2496,11 +2531,11 @@ apply_handle_commit(StringInfo s)
 
 			/*
 			 * Build a dependency between this transaction and the lastly
-			 * committed transaction to preserve the commit order.
+			 * committed transaction to preserve the commit order. Then try to
+			 * send a COMMIT message if succeeded.
 			 */
-			build_dependency_with_last_committed_txn(winfo);
-
-			if (pa_send_data(winfo, s->len, s->data))
+			if (build_dependency_with_last_committed_txn(winfo) &&
+				pa_send_data(winfo, s->len, s->data))
 			{
 				/* Cache the remote_xid */
 				last_parallelized_remote_xid = remote_xid;
@@ -2511,12 +2546,29 @@ apply_handle_commit(StringInfo s)
 			}
 
 			/*
-			 * TODO: Support switching to PARTIAL_SERIALIZE mode when the send
-			 * buffer becomes full.
+			 * Switch to serialize mode when we are not able to send the
+			 * change to parallel apply worker.
 			 */
-			ereport(ERROR,
-					errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
-					errmsg("could not send data to the logical replication parallel apply worker"));
+			pa_switch_to_partial_serialize(winfo, true);
+
+			pg_fallthrough;
+		case TRANS_LEADER_PARTIAL_SERIALIZE:
+			Assert(winfo);
+
+			/*
+			 * Build a dependency with the last committed transaction if not
+			 * already done.
+			 */
+			if (apply_action != TRANS_LEADER_SEND_TO_PARALLEL)
+				build_dependency_with_last_committed_txn(winfo);
+
+			stream_open_and_write_change(remote_xid, LOGICAL_REP_MSG_COMMIT,
+										 &original_msg);
+
+			pa_set_fileset_state(winfo->shared, FS_SERIALIZE_DONE);
+
+			/* Finish processing the transaction. */
+			pa_xact_finish(winfo, commit_data.end_lsn);
 			break;
 
 		case TRANS_PARALLEL_APPLY:
@@ -2569,6 +2621,9 @@ apply_handle_begin_prepare(StringInfo s)
 	ParallelApplyWorkerInfo *winfo;
 	TransApplyAction apply_action;
 
+	/* Save the message before it is consumed. */
+	StringInfoData original_msg = *s;
+
 	/* Tablesync should never receive prepare. */
 	if (am_tablesync_worker())
 		ereport(ERROR,
@@ -2608,12 +2663,19 @@ apply_handle_begin_prepare(StringInfo s)
 			}
 
 			/*
-			 * TODO: Support switching to PARTIAL_SERIALIZE mode when the send
-			 * buffer becomes full.
+			 * Switch to serialize mode when we are not able to send the
+			 * change to parallel apply worker.
 			 */
-			ereport(ERROR,
-					errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
-					errmsg("could not send data to the logical replication parallel apply worker"));
+			pa_switch_to_partial_serialize(winfo, true);
+
+			pg_fallthrough;
+		case TRANS_LEADER_PARTIAL_SERIALIZE:
+			Assert(winfo);
+
+			stream_write_change(LOGICAL_REP_MSG_BEGIN_PREPARE, &original_msg);
+
+			/* Cache the parallel apply worker for this transaction. */
+			pa_set_stream_apply_worker(winfo);
 			break;
 
 		case TRANS_PARALLEL_APPLY:
@@ -2679,6 +2741,9 @@ apply_handle_prepare(StringInfo s)
 	ParallelApplyWorkerInfo *winfo;
 	TransApplyAction apply_action;
 
+	/* Save the message before it is consumed. */
+	StringInfoData original_msg = *s;
+
 	logicalrep_read_prepare(s, &prepare_data);
 
 	if (prepare_data.prepare_lsn != remote_final_lsn)
@@ -2748,11 +2813,11 @@ apply_handle_prepare(StringInfo s)
 
 			/*
 			 * Build a dependency between this transaction and the lastly
-			 * committed transaction to preserve the commit order.
+			 * committed transaction to preserve the commit order. Then try to
+			 * send a COMMIT message if succeeded.
 			 */
-			build_dependency_with_last_committed_txn(winfo);
-
-			if (pa_send_data(winfo, s->len, s->data))
+			if (build_dependency_with_last_committed_txn(winfo) &&
+				pa_send_data(winfo, s->len, s->data))
 			{
 				/* Cache the remote_xid */
 				last_parallelized_remote_xid = remote_xid;
@@ -2763,12 +2828,29 @@ apply_handle_prepare(StringInfo s)
 			}
 
 			/*
-			 * TODO: Support switching to PARTIAL_SERIALIZE mode when the send
-			 * buffer becomes full.
+			 * Switch to serialize mode when we are not able to send the
+			 * change to parallel apply worker.
 			 */
-			ereport(ERROR,
-					errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
-					errmsg("could not send data to the logical replication parallel apply worker"));
+			pa_switch_to_partial_serialize(winfo, true);
+			pg_fallthrough;
+
+		case TRANS_LEADER_PARTIAL_SERIALIZE:
+			Assert(winfo);
+
+			/*
+			 * Build a dependency with the last committed transaction if not
+			 * already done.
+			 */
+			if (apply_action != TRANS_LEADER_SEND_TO_PARALLEL)
+				build_dependency_with_last_committed_txn(winfo);
+
+			stream_open_and_write_change(remote_xid, LOGICAL_REP_MSG_PREPARE,
+										 &original_msg);
+
+			pa_set_fileset_state(winfo->shared, FS_SERIALIZE_DONE);
+
+			/* Finish processing the transaction. */
+			pa_xact_finish(winfo, prepare_data.end_lsn);
 			break;
 
 		case TRANS_PARALLEL_APPLY:
@@ -3043,9 +3125,8 @@ apply_handle_stream_prepare(StringInfo s)
 			 * Build a dependency between this transaction and the lastly
 			 * committed transaction to preserve the commit order.
 			 */
-			build_dependency_with_last_committed_txn(winfo);
-
-			if (pa_send_data(winfo, s->len, s->data))
+			if (build_dependency_with_last_committed_txn(winfo) &&
+				pa_send_data(winfo, s->len, s->data))
 			{
 				/* Finish processing the streaming transaction. */
 				pa_xact_finish(winfo, prepare_data.end_lsn);
@@ -3923,9 +4004,8 @@ apply_handle_stream_commit(StringInfo s)
 			 * Build a dependency between this transaction and the lastly
 			 * committed transaction to preserve the commit order.
 			 */
-			build_dependency_with_last_committed_txn(winfo);
-
-			if (pa_send_data(winfo, s->len, s->data))
+			if (build_dependency_with_last_committed_txn(winfo) &&
+				pa_send_data(winfo, s->len, s->data))
 			{
 				/* Finish processing the streaming transaction. */
 				pa_xact_finish(winfo, commit_data.end_lsn);
diff --git a/src/test/subscription/t/050_parallel_apply.pl b/src/test/subscription/t/050_parallel_apply.pl
index 896244a42fd..bd51d98d885 100644
--- a/src/test/subscription/t/050_parallel_apply.pl
+++ b/src/test/subscription/t/050_parallel_apply.pl
@@ -417,4 +417,34 @@ $node_subscriber->safe_psql('postgres', qq[
 
 $node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
 
+# Cleanup
+$node_publisher->safe_psql('postgres', "DELETE FROM regress_tab;");
+$node_publisher->wait_for_catchup('regress_sub');
+
+# Force the leader apply worker to serialize changes to disk
+$node_subscriber->append_conf('postgresql.conf',
+	"debug_logical_replication_streaming = immediate");
+$node_subscriber->reload;
+
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(1, 10), 'test');");
+
+$node_publisher->wait_for_catchup('regress_sub');
+
+# Reset to buffered mode so that subsequent tests send changes via shared
+# memory.
+$node_subscriber->append_conf('postgresql.conf',
+	"debug_logical_replication_streaming = buffered");
+$node_subscriber->reload;
+
+# Verify that changes have been serialized
+$node_subscriber->wait_for_log(
+	qr/LOG: ( [A-Z0-9]+:)? logical replication apply worker will serialize the remaining changes of remote transaction \d+ to a file/,
+	$offset);
+
+# Verify that the parallel apply worker can restore serialized changes correctly
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab");
+is ($result, 10, 'inserts are replicated to subscriber');
+
 done_testing();
-- 
2.43.0



  [application/octet-stream] v19-0009-Support-dependency-tracking-via-foreign-keys.patch (17.0K, ../../TY4PR01MB17718A4AB7F564C8DB0D6F7DC94102@TY4PR01MB17718.jpnprd01.prod.outlook.com/4-v19-0009-Support-dependency-tracking-via-foreign-keys.patch)
  download | inline diff:
From c05f0b4a0b18feb4d71a01690572fedcbd4f1406 Mon Sep 17 00:00:00 2001
From: Zhijie Hou <[email protected]>
Date: Thu, 4 Jun 2026 13:29:18 +0800
Subject: [PATCH v19 09/10] Support dependency tracking via foreign keys

Logical replication's parallel apply mechanism did not account for foreign-key
dependencies. This can cause a failure because the reference column may be
committed before the referenced column.

This patch extends the parallel apply's dependency tracking to account for
foreign keys in the subscriber table. One assumption here is that referenced
columns can be registered to the identity hash table.
The leader apply worker checks to determine whether values in referencing
columns have already been registered or not, and regards that there is a
dependency if found.
---
 src/backend/replication/logical/relation.c    | 185 ++++++++++++++++++
 src/backend/replication/logical/worker.c      | 145 ++++++++++++++
 src/include/replication/logicalrelation.h     |  11 ++
 src/test/subscription/t/050_parallel_apply.pl |  53 ++++-
 4 files changed, 391 insertions(+), 3 deletions(-)

diff --git a/src/backend/replication/logical/relation.c b/src/backend/replication/logical/relation.c
index 6665f5c0cc9..83602dcd4cf 100644
--- a/src/backend/replication/logical/relation.c
+++ b/src/backend/replication/logical/relation.c
@@ -21,6 +21,7 @@
 #include "access/genam.h"
 #include "access/table.h"
 #include "catalog/namespace.h"
+#include "catalog/pg_constraint.h"
 #include "catalog/pg_proc.h"
 #include "catalog/pg_subscription_rel.h"
 #include "commands/trigger.h"
@@ -143,6 +144,21 @@ free_local_unique_indexes(LogicalRepRelMapEntry *entry)
 	entry->local_unique_indexes = NIL;
 }
 
+/*
+ * Release foreign key list
+ */
+static void
+free_local_fkeys(LogicalRepRelMapEntry *entry)
+{
+	Assert(am_leader_apply_worker());
+
+	foreach_ptr(LogicalRepSubscriberFK, fkinfo, entry->local_fkeys)
+		bms_free(fkinfo->conkeys);
+
+	list_free_deep(entry->local_fkeys);
+	entry->local_fkeys = NIL;
+}
+
 /*
  * Free the entry of a relation map cache.
  */
@@ -173,6 +189,9 @@ logicalrep_relmap_free_entry(LogicalRepRelMapEntry *entry)
 
 	if (entry->local_unique_indexes != NIL)
 		free_local_unique_indexes(entry);
+
+	if (entry->local_fkeys != NIL)
+		free_local_fkeys(entry);
 }
 
 /*
@@ -499,6 +518,171 @@ collect_local_indexes(LogicalRepRelMapEntry *entry)
 	list_free(idxlist);
 }
 
+/*
+ * Search a relmap entry by local relation OID.
+ */
+static LogicalRepRelMapEntry *
+logicalrep_get_relentry_by_local_oid(Oid localreloid)
+{
+	HASH_SEQ_STATUS status;
+	LogicalRepRelMapEntry *entry = NULL;
+
+	if (LogicalRepRelMap == NULL)
+		return NULL;
+
+	/*
+	 * Each entry must be checked individually. Because the key of
+	 * LogicalRepRelMap is the "remote" relid but we only have the local one.
+	 *
+	 * Note: This iteration ignores relations that have not yet been
+	 * registered in LogicalRepRelMap. It is OK because such relations have
+	 * not been modified yet since the subscriber started receiving changes.
+	 */
+	hash_seq_init(&status, LogicalRepRelMap);
+	while ((entry = (LogicalRepRelMapEntry *) hash_seq_search(&status)) != NULL)
+	{
+		if (entry->localreloid == localreloid)
+		{
+			hash_seq_term(&status);
+			return entry;
+		}
+	}
+
+	return NULL;
+}
+
+/*
+ * Return true if the FK constraint is always deferred.
+ */
+static bool
+foreign_key_is_always_deferred(Oid conoid)
+{
+	HeapTuple	tup;
+	Form_pg_constraint con;
+	bool		deferred;
+
+	tup = SearchSysCache1(CONSTROID, ObjectIdGetDatum(conoid));
+	if (!HeapTupleIsValid(tup))
+		elog(ERROR, "cache lookup failed for foreign key %u", conoid);
+
+	con = (Form_pg_constraint) GETSTRUCT(tup);
+	deferred = con->condeferrable && con->condeferred;
+	ReleaseSysCache(tup);
+
+	return deferred;
+}
+
+/*
+ * Collect all local foreign keys that can be used for dependency tracking.
+ */
+static void
+collect_local_fkeys(LogicalRepRelMapEntry *entry)
+{
+	List	   *fkeys;
+
+	if (entry->local_fkeys != NIL)
+		free_local_fkeys(entry);
+
+	entry->local_fkeys_collected = true;
+
+	/*
+	 * Get the list of foreign keys for the relation.
+	 *
+	 * XXX: Apart from RelationGetIndexList(), the returned list is a part of
+	 * relcache and must be copied before doing anything. See comments atop
+	 * RelationGetFKeyList().
+	 */
+	fkeys = copyObject(RelationGetFKeyList(entry->localrel));
+
+	/* Quick exit if there are no foreign keys */
+	if (fkeys == NIL)
+		return;
+
+	foreach_ptr(ForeignKeyCacheInfo, fk, fkeys)
+	{
+		LogicalRepRelMapEntry *refentry;
+		Bitmapset  *fkkeys = NULL;
+		bool		suitable = true;
+
+		/*
+		 * Skip NOT ENFORCED constraints because they won't be checked at any
+		 * times.
+		 */
+		if (!fk->conenforced)
+			continue;
+
+		/*
+		 * Skip if the foreign key constraint is always deferred. The commit
+		 * ordering is always preserved thus the constraint can be checked in
+		 * correct order.
+		 */
+		if (foreign_key_is_always_deferred(fk->conoid))
+			continue;
+
+		/* Find the referenced relation by the local OID */
+		refentry = logicalrep_get_relentry_by_local_oid(fk->confrelid);
+
+		/*
+		 * Skip if the referenced relation is not the target of this
+		 * subscription.
+		 */
+		if (!refentry)
+			continue;
+
+		/* Seek each attributes and add to a Bitmap */
+		for (int i = 0; i < fk->nkeys; i++)
+		{
+			AttrNumber	localcol = fk->conkey[i];
+			int			remotecol =
+				entry->attrmap->attnums[AttrNumberGetAttrOffset(localcol)];
+
+			/* Skip if the column does not exist on publisher node */
+			if (remotecol < 0)
+			{
+				suitable = false;
+				break;
+			}
+
+			/*
+			 * XXX: What if the FK column is specified with different order?
+			 * E.g., there is a table (a, b) and other table refers like
+			 * REFERENCES (b, a). Will it work correctly?
+			 */
+			fkkeys = bms_add_member(fkkeys, remotecol);
+		}
+
+		/*
+		 * One of the columns does not exist on the publisher side, skip such
+		 * a constraint.
+		 */
+		if (!suitable)
+		{
+			if (fkkeys)
+				bms_free(fkkeys);
+
+			continue;
+		}
+
+		if (fkkeys)
+		{
+			MemoryContext oldctx;
+			LogicalRepSubscriberFK *fkinfo;
+
+			oldctx = MemoryContextSwitchTo(LogicalRepRelMapContext);
+			fkinfo = palloc(sizeof(LogicalRepSubscriberFK));
+			fkinfo->conoid = fk->conoid;
+			fkinfo->ref_remoteid = refentry->remoterel.remoteid;
+			fkinfo->conkeys = bms_copy(fkkeys);
+
+			bms_free(fkkeys);
+			entry->local_fkeys = lappend(entry->local_fkeys, fkinfo);
+			MemoryContextSwitchTo(oldctx);
+		}
+	}
+
+	list_free_deep(fkeys);
+}
+
 /*
  * Check all local triggers for the relation to see the parallelizability.
  *
@@ -715,6 +899,7 @@ logicalrep_rel_load(LogicalRepRelMapEntry *entry, LogicalRepRelId remoteid,
 		{
 			entry->parallel_safe = LOGICALREP_PARALLEL_UNKNOWN;
 			collect_local_indexes(entry);
+			collect_local_fkeys(entry);
 			check_defined_triggers(entry);
 		}
 
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index a212bc6013d..6f8d83c6b7b 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -1205,6 +1205,138 @@ check_and_record_local_key_dependency(Oid relid,
 	}
 }
 
+/*
+ * Lookup-only dependency check by key. Does not register/update entries.
+ */
+static void
+find_dependencies_by_key(ReplicaIdentityKey *key,
+						 TransactionId new_depended_xid,
+						 List **depends_on_xids)
+{
+	ReplicaIdentityEntry *rientry;
+	MemoryContext oldctx;
+
+	Assert(replica_identity_table);
+
+	oldctx = MemoryContextSwitchTo(ApplyContext);
+
+	rientry = replica_identity_lookup(replica_identity_table, key);
+	free_replica_identity_key(key);
+
+	MemoryContextSwitchTo(oldctx);
+
+	/*
+	 * Return if no entry exists, or if the current transaction was the last one
+	 * to modify the key.
+	 */
+	if (!rientry || TransactionIdEquals(rientry->remote_xid, new_depended_xid))
+		return;
+
+	/*
+	 * Clean up committed entries while on it.
+	 */
+	if (pa_transaction_committed(rientry->remote_xid))
+	{
+		free_replica_identity_key(rientry->keydata);
+		replica_identity_delete_item(replica_identity_table, rientry);
+		return;
+	}
+
+	check_and_append_xid_dependency(&rientry->remote_xid, depends_on_xids);
+}
+
+/*
+ * Check for dependencies on preceding transactions that modify the referenced
+ * unique key of foreign keys.
+ */
+static void
+check_and_record_fkey_dependency(Oid relid,
+								 LogicalRepTupleData *original_data,
+								 TransactionId new_depended_xid,
+								 List **depends_on_xids)
+{
+	LogicalRepRelMapEntry *relentry;
+	LogicalRepTupleData *ridata;
+	ReplicaIdentityKey *rikey;
+	MemoryContext oldctx;
+
+	Assert(depends_on_xids);
+
+	relentry = logicalrep_get_relentry(relid);
+
+	Assert(relentry);
+
+	/*
+	 * We have already checked the local unique indexes and also gathered the
+	 * FK info at that time.
+	 *
+	 * XXX: Should we check and load again?
+	 */
+	Assert(relentry->local_fkeys_collected);
+
+	foreach_ptr(LogicalRepSubscriberFK, fkinfo, relentry->local_fkeys)
+	{
+		int			columns = bms_num_members(fkinfo->conkeys);
+		bool		suitable = true;
+
+		Assert(columns);
+
+		for (int i = 0; i < original_data->ncols; i++)
+		{
+			if (!bms_is_member(i, fkinfo->conkeys))
+				continue;
+
+			/* Skip if the column is NULL or not changed */
+			if (original_data->colstatus[i] == LOGICALREP_COLUMN_NULL ||
+				original_data->colstatus[i] == LOGICALREP_COLUMN_UNCHANGED)
+			{
+				suitable = false;
+				break;
+			}
+		}
+
+		if (!suitable)
+			continue;
+
+		oldctx = MemoryContextSwitchTo(ApplyContext);
+
+		/* Allocate space for replica identity values */
+		ridata = palloc0_object(LogicalRepTupleData);
+		ridata->colvalues = palloc0_array(StringInfoData, columns);
+		ridata->colstatus = palloc0_array(char, columns);
+		ridata->ncols = columns;
+
+		for (int i_original = 0, i_key = 0; i_original < original_data->ncols; i_original++)
+		{
+			StringInfo	original_colvalue;
+
+			if (!bms_is_member(i_original, fkinfo->conkeys))
+				continue;
+
+			original_colvalue = &original_data->colvalues[i_original];
+			initStringInfoExt(&ridata->colvalues[i_key], original_colvalue->len + 1);
+			appendStringInfoString(&ridata->colvalues[i_key], original_colvalue->data);
+
+			ridata->colstatus[i_key] = original_data->colstatus[i_original];
+			i_key++;
+		}
+
+		rikey = palloc0_object(ReplicaIdentityKey);
+		rikey->relid = fkinfo->ref_remoteid;
+
+		/*
+		 * XXX: use LOGICALREP_KEY_LOCAL_UNIQUE to compare with local unique
+		 * indexes.
+		 */
+		rikey->kind = LOGICALREP_KEY_LOCAL_UNIQUE;
+		rikey->data = ridata;
+
+		MemoryContextSwitchTo(oldctx);
+
+		find_dependencies_by_key(rikey, new_depended_xid, depends_on_xids);
+	}
+}
+
 /*
  * Check for preceding transactions (other than current one 'new_depended_xid')
  * that involve insert, delete, or update operations on the specified table, and
@@ -1402,6 +1534,9 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			check_and_record_local_key_dependency(relid, &newtup,
 												  new_depended_xid,
 												  &depends_on_xids);
+			check_and_record_fkey_dependency(relid, &newtup,
+											 new_depended_xid,
+											 &depends_on_xids);
 			check_dependency_for_parallel_safety(relid, new_depended_xid,
 												 &depends_on_xids);
 			break;
@@ -1419,6 +1554,10 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 													  new_depended_xid,
 													  &depends_on_xids);
 
+				check_and_record_fkey_dependency(relid, &oldtup,
+												 new_depended_xid,
+												 &depends_on_xids);
+
 				check_dependency_for_parallel_safety(relid, new_depended_xid,
 													 &depends_on_xids);
 
@@ -1437,6 +1576,9 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			check_and_record_local_key_dependency(relid, &newtup,
 												  new_depended_xid,
 												  &depends_on_xids);
+			check_and_record_fkey_dependency(relid, &newtup,
+											 new_depended_xid,
+											 &depends_on_xids);
 			check_dependency_for_parallel_safety(relid, new_depended_xid,
 												 &depends_on_xids);
 			break;
@@ -1448,6 +1590,9 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			check_and_record_local_key_dependency(relid, &oldtup,
 												  new_depended_xid,
 												  &depends_on_xids);
+			check_and_record_fkey_dependency(relid, &oldtup,
+											 new_depended_xid,
+											 &depends_on_xids);
 			check_dependency_for_parallel_safety(relid, new_depended_xid,
 												 &depends_on_xids);
 			break;
diff --git a/src/include/replication/logicalrelation.h b/src/include/replication/logicalrelation.h
index 241c4dda13c..ff25cd3ce4f 100644
--- a/src/include/replication/logicalrelation.h
+++ b/src/include/replication/logicalrelation.h
@@ -22,6 +22,13 @@ typedef struct LogicalRepSubscriberIdx
 	Bitmapset  *indexkeys;	/* Bitmap of key columns *on remote* */
 } LogicalRepSubscriberIdx;
 
+typedef struct LogicalRepSubscriberFK
+{
+	Oid			conoid;		/* OID of the FK constraint */
+	LogicalRepRelId ref_remoteid; /* referenced remote relid */
+	Bitmapset  *conkeys;	/* Bitmap of key columns *on remote* */
+} LogicalRepSubscriberFK;
+
 typedef struct LogicalRepRelMapEntry
 {
 	LogicalRepRelation remoterel;	/* key is remoterel.remoteid */
@@ -56,6 +63,10 @@ typedef struct LogicalRepRelMapEntry
 	List	   *local_unique_indexes;
 	bool		local_unique_indexes_collected;
 
+	/* Local foreign keys. Used for dependency tracking */
+	List	   *local_fkeys;
+	bool		local_fkeys_collected;
+
 	/*
 	 * Whether the relation can be applied in parallel or not. It is
 	 * distinglish whether defined triggers are the immutable or not.
diff --git a/src/test/subscription/t/050_parallel_apply.pl b/src/test/subscription/t/050_parallel_apply.pl
index ae539f84639..896244a42fd 100644
--- a/src/test/subscription/t/050_parallel_apply.pl
+++ b/src/test/subscription/t/050_parallel_apply.pl
@@ -33,6 +33,8 @@ $node_publisher->safe_psql(
     CREATE UNIQUE INDEX tab_toast_ri_index on tab_toast (a, b);
     ALTER TABLE tab_toast REPLICA IDENTITY USING INDEX tab_toast_ri_index;
     INSERT INTO tab_toast(a, b) VALUES(repeat('1234567890', 200), '1234567890');
+
+    CREATE TABLE regress_tab_fk (id int PRIMARY KEY, fk int);
 ));
 $node_publisher->safe_psql('postgres',
     "INSERT INTO regress_tab VALUES (generate_series(1, 10), 'test');");
@@ -71,6 +73,8 @@ $node_subscriber->safe_psql(
     ALTER TABLE tab_toast ALTER COLUMN a SET STORAGE EXTERNAL;
     CREATE UNIQUE INDEX tab_toast_ri_index on tab_toast (a, b);
     ALTER TABLE tab_toast REPLICA IDENTITY USING INDEX tab_toast_ri_index;
+
+    CREATE TABLE regress_tab_fk (id int PRIMARY KEY, fk int REFERENCES regress_tab (id));
 ));
 $node_subscriber->safe_psql('postgres',
     "CREATE SUBSCRIPTION regress_sub CONNECTION '$publisher_connstr' PUBLICATION regress_pub;");
@@ -315,8 +319,8 @@ $h->query_safe("COMMIT;");
 # subscriber during parallel apply.
 ##################################################
 
-# Truncate the data for upcoming tests
-$node_publisher->safe_psql('postgres', "TRUNCATE TABLE regress_tab;");
+# delete the data for upcoming tests
+$node_publisher->safe_psql('postgres', "DELETE FROM regress_tab;");
 $node_publisher->wait_for_catchup('regress_sub');
 
 # Define an unique index on subscriber
@@ -367,7 +371,50 @@ $node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset
 
 # Cleanup
 $node_subscriber->safe_psql('postgres', "DROP INDEX regress_tab_value_idx;");
-$node_publisher->safe_psql('postgres', "TRUNCATE TABLE regress_tab;");
+$node_publisher->safe_psql('postgres', "DELETE FROM regress_tab;");
+$node_publisher->wait_for_catchup('regress_sub');
+
+##################################################
+# Test that the dependency tracking works correctly for local foreign key on
+# subscriber during parallel apply.
+##################################################
+
+# Insert an initial tuple
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (1, 'initial tuple');");
 $node_publisher->wait_for_catchup('regress_sub');
 
+# Attach an injection_point. Parallel workers would wait before the commit
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+# Insert a tuple on publisher. Parallel worker would wait at the injection
+# point
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (2, 'tmp');");
+
+# Wait until the parallel worker enters the injection point.
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+$offset = -s $node_subscriber->logfile;
+
+# Insert tuples on publisher again. This transaction waits for referring tuple
+# is committed.
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab_fk VALUES (1, 1);");
+
+# Verify the parallel worker waits for the transaction
+$str = $node_subscriber->wait_for_log(qr/wait for depended xid ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /wait for depended xid ([1-9][0-9]+)/;
+
+# Wakeup the parallel worker
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+
 done_testing();
-- 
2.43.0



  [application/octet-stream] v19-0008-Support-dependency-tracking-via-local-unique-ind.patch (24.0K, ../../TY4PR01MB17718A4AB7F564C8DB0D6F7DC94102@TY4PR01MB17718.jpnprd01.prod.outlook.com/5-v19-0008-Support-dependency-tracking-via-local-unique-ind.patch)
  download | inline diff:
From 2cb151a82e8c33fe8d9ff2c8d83208a90df85c9f Mon Sep 17 00:00:00 2001
From: Zhijie Hou <[email protected]>
Date: Thu, 4 Jun 2026 13:23:55 +0800
Subject: [PATCH v19 08/10] Support dependency tracking via local unique
 indexes

Currently, logical replication's parallel apply mechanism tracks dependencies
primarily based on the REPLICA IDENTITY defined on the publisher table.
However, local subscriber tables might have additional unique indexes that
could effectively serve as dependency keys, even if they don't correspond to
the publisher's REPLICA IDENTITY. Failing to track these additional unique
keys can lead to incorrect data and/or deadlocks during parallel application.

This patch extends the parallel apply's dependency tracking to consider
local unique indexes on the subscriber table. This is achieved by extending
the existing Replica Identity hash table to also store dependency information
based on these local unique indexes.

The LogicalRepRelMapEntry structure is extended to store details about these
local unique indexes. This information is collected and cached when
dependency checking is first performed for a remote transaction on a given
relation. This collection process requires to be in a transaction to access
system catalog information.
---
 src/backend/replication/logical/relation.c    | 161 ++++++++++-
 src/backend/replication/logical/worker.c      | 252 ++++++++++++++----
 src/backend/storage/lmgr/deadlock.c           |   1 -
 src/include/replication/logicalrelation.h     |  14 +
 src/test/subscription/t/050_parallel_apply.pl |  60 +++++
 src/tools/pgindent/typedefs.list              |   2 +
 6 files changed, 439 insertions(+), 51 deletions(-)

diff --git a/src/backend/replication/logical/relation.c b/src/backend/replication/logical/relation.c
index eeb85f8cc5d..6665f5c0cc9 100644
--- a/src/backend/replication/logical/relation.c
+++ b/src/backend/replication/logical/relation.c
@@ -128,6 +128,21 @@ logicalrep_relmap_init(void)
 								  (Datum) 0);
 }
 
+/*
+ * Release local index list
+ */
+static void
+free_local_unique_indexes(LogicalRepRelMapEntry *entry)
+{
+	Assert(am_leader_apply_worker());
+
+	foreach_ptr(LogicalRepSubscriberIdx, idxinfo, entry->local_unique_indexes)
+		bms_free(idxinfo->indexkeys);
+
+	list_free_deep(entry->local_unique_indexes);
+	entry->local_unique_indexes = NIL;
+}
+
 /*
  * Free the entry of a relation map cache.
  */
@@ -155,6 +170,9 @@ logicalrep_relmap_free_entry(LogicalRepRelMapEntry *entry)
 
 	if (entry->attrmap)
 		free_attrmap(entry->attrmap);
+
+	if (entry->local_unique_indexes != NIL)
+		free_local_unique_indexes(entry);
 }
 
 /*
@@ -361,6 +379,126 @@ logicalrep_rel_mark_updatable(LogicalRepRelMapEntry *entry)
 	}
 }
 
+/*
+ * Collect all local unique indexes that can be used for dependency tracking.
+ */
+static void
+collect_local_indexes(LogicalRepRelMapEntry *entry)
+{
+	List	   *idxlist;
+
+	if (entry->local_unique_indexes != NIL)
+		free_local_unique_indexes(entry);
+
+	entry->local_unique_indexes_collected = true;
+
+	idxlist = RelationGetIndexList(entry->localrel);
+
+	/* Quick exit if there are no indexes */
+	if (idxlist == NIL)
+		return;
+
+	/* Iterate indexes to list all usable indexes */
+	foreach_oid(idxoid, idxlist)
+	{
+		Relation	idxrel;
+		int			indnkeys;
+		AttrMap    *attrmap;
+		Bitmapset  *indexkeys = NULL;
+		bool		suitable = true;
+
+		idxrel = index_open(idxoid, AccessShareLock);
+
+		/*
+		 * Check whether the index can be used for the dependency tracking.
+		 *
+		 * For simplification, the same condition as REPLICA IDENTITY FULL,
+		 * plus it must be a unique index.
+		 */
+		if (!(idxrel->rd_index->indisunique &&
+			  IsIndexUsableForReplicaIdentityFull(idxrel, entry->attrmap)))
+		{
+			index_close(idxrel, AccessShareLock);
+			continue;
+		}
+
+		indnkeys = idxrel->rd_index->indnkeyatts;
+		attrmap = entry->attrmap;
+
+		Assert(indnkeys);
+
+		/* Seek each attributes and add to a Bitmap */
+		for (int i = 0; i < indnkeys; i++)
+		{
+			AttrNumber	localcol = idxrel->rd_index->indkey.values[i];
+			AttrNumber	remotecol;
+
+			/*
+			 * XXX: Mark a relation as parallel-unsafe if it has expression
+			 * indexes because we cannot compute the hash value for the
+			 * dependency tracking. For safety, transactions that modify such
+			 * tables can wait for applications till the lastly dispatched
+			 * transaction is committed.
+			 */
+			if (!AttributeNumberIsValid(localcol))
+			{
+				entry->parallel_safe = LOGICALREP_PARALLEL_RESTRICTED;
+				suitable = false;
+				break;
+			}
+
+			remotecol = attrmap->attnums[AttrNumberGetAttrOffset(localcol)];
+
+			/*
+			 * Skip if the column does not exist on publisher node. In this
+			 * case the replicated tuples always have NULL or default value.
+			 */
+			if (remotecol < 0)
+			{
+				suitable = false;
+				break;
+			}
+
+			/* Checks are passed, remember the attribute */
+			indexkeys = bms_add_member(indexkeys, remotecol);
+		}
+
+		index_close(idxrel, AccessShareLock);
+
+		/*
+		 * Skip using the index if it is not suitable. This can happen if 1)
+		 * one of the columns does not exist on the publisher side, or 2)
+		 * there is an expression column.
+		 */
+		if (!suitable)
+		{
+			if (indexkeys)
+				bms_free(indexkeys);
+
+			continue;
+		}
+
+		/* This index is usable, store on memory */
+		if (indexkeys)
+		{
+			MemoryContext oldctx;
+			LogicalRepSubscriberIdx *idxinfo;
+
+			oldctx = MemoryContextSwitchTo(LogicalRepRelMapContext);
+			idxinfo = palloc(sizeof(LogicalRepSubscriberIdx));
+			idxinfo->indexoid = idxoid;
+			idxinfo->indexkeys = bms_copy(indexkeys);
+			entry->local_unique_indexes =
+				lappend(entry->local_unique_indexes, idxinfo);
+
+			pfree(indexkeys);
+			MemoryContextSwitchTo(oldctx);
+		}
+	}
+
+	list_free(idxlist);
+}
+
 /*
  * Check all local triggers for the relation to see the parallelizability.
  *
@@ -370,7 +508,16 @@ logicalrep_rel_mark_updatable(LogicalRepRelMapEntry *entry)
 static void
 check_defined_triggers(LogicalRepRelMapEntry *entry)
 {
-	TriggerDesc *trigdesc = entry->localrel->trigdesc;
+	TriggerDesc *trigdesc;
+
+	/*
+	 * Skip if the parallelizability has already been checked. Possilble if
+	 * the relation has expression indexes.
+	 */
+	if (entry->parallel_safe != LOGICALREP_PARALLEL_UNKNOWN)
+		return;
+
+	trigdesc = entry->localrel->trigdesc;
 
 	/* Quick exit if triffer is not defined */
 	if (trigdesc == NULL)
@@ -411,7 +558,7 @@ check_defined_triggers(LogicalRepRelMapEntry *entry)
  * If the key is given, the corresponding entry is first searched in the hash
  * table and processed as in the above case. At the end, logical replication is
  * closed.
-  */
+ */
 void
 logicalrep_rel_load(LogicalRepRelMapEntry *entry, LogicalRepRelId remoteid,
 					LOCKMODE lockmode)
@@ -565,7 +712,11 @@ logicalrep_rel_load(LogicalRepRelMapEntry *entry, LogicalRepRelId remoteid,
 		 * tracking.
 		 */
 		if (am_leader_apply_worker())
+		{
+			entry->parallel_safe = LOGICALREP_PARALLEL_UNKNOWN;
+			collect_local_indexes(entry);
 			check_defined_triggers(entry);
+		}
 
 		entry->localrelvalid = true;
 	}
@@ -867,6 +1018,12 @@ logicalrep_partition_open(LogicalRepRelMapEntry *root,
 	entry->localindexoid = FindLogicalRepLocalIndex(partrel, remoterel,
 													entry->attrmap);
 
+	/*
+	 * TODO: Parallel apply does not support the parallel apply for now. Just
+	 * mark local indexes are collected.
+	 */
+	entry->local_unique_indexes_collected = true;
+
 	entry->localrelvalid = true;
 
 	return entry;
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index de665b8d4b3..a212bc6013d 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -578,10 +578,20 @@ typedef struct ApplySubXactData
 
 static ApplySubXactData subxact_data = {0, 0, InvalidTransactionId, NULL};
 
+/*
+ * Type of key used for dependency tracking.
+ */
+typedef enum LogicalRepKeyKind
+{
+	LOGICALREP_KEY_REPLICA_IDENTITY,
+	LOGICALREP_KEY_LOCAL_UNIQUE
+} LogicalRepKeyKind;
+
 /* Hash table key for replica_identity_table */
 typedef struct ReplicaIdentityKey
 {
 	Oid			relid;
+	LogicalRepKeyKind kind;
 	LogicalRepTupleData *data;
 } ReplicaIdentityKey;
 
@@ -760,7 +770,8 @@ static bool
 hash_replica_identity_compare(ReplicaIdentityKey *a, ReplicaIdentityKey *b)
 {
 	if (a->relid != b->relid ||
-		a->data->ncols != b->data->ncols)
+		a->data->ncols != b->data->ncols ||
+		a->kind != b->kind)
 		return false;
 
 	for (int i = 0; i < a->data->ncols; i++)
@@ -768,6 +779,9 @@ hash_replica_identity_compare(ReplicaIdentityKey *a, ReplicaIdentityKey *b)
 		if (a->data->colstatus[i] != b->data->colstatus[i])
 			return false;
 
+		if (a->data->colstatus[i] == LOGICALREP_COLUMN_NULL)
+			continue;
+
 		if (a->data->colvalues[i].len != b->data->colvalues[i].len)
 			return false;
 
@@ -895,6 +909,89 @@ check_and_append_xid_dependency(TransactionId *xid, List **depends_on_xids)
 	*depends_on_xids = lappend_xid(*depends_on_xids, *xid);
 }
 
+/*
+ * Common function for checking dependency by using the key. Used by both
+ * check_and_record_ri_dependency and check_and_record_local_key_dependency.
+ */
+static void
+check_and_record_key_dependency(ReplicaIdentityKey *key,
+								TransactionId new_depended_xid,
+								List **depends_on_xids)
+{
+	ReplicaIdentityEntry *rientry;
+	bool		found = false;
+
+	/*
+	 * The new xid could be invalid if the transaction will be applied by the
+	 * leader itself which means all the changes will be committed before
+	 * processing next transaction. In this case, we only need to check for
+	 * dependencies on preceding transactions, there is no need to record a new
+	 * dependency for subsequent transactions to wait on.
+	 */
+	if (!TransactionIdIsValid(new_depended_xid))
+	{
+		rientry = replica_identity_lookup(replica_identity_table, key);
+		free_replica_identity_key(key);
+
+		/* No dependency detected */
+		if (!rientry)
+			return;
+
+		elog(DEBUG1,
+			 key->kind == LOGICALREP_KEY_REPLICA_IDENTITY ?
+			 "found conflicting replica identity change from %u" :
+			 "found conflicting local unique change from %u",
+			 rientry->remote_xid);
+
+		Assert(TransactionIdIsValid(rientry->remote_xid));
+		check_and_append_xid_dependency(&rientry->remote_xid, depends_on_xids);
+
+		/*
+		 * Remove the entry if the remote transaction has been committed locally
+		 * and no new dependency needs to be added.
+		 */
+		if (!TransactionIdIsValid(rientry->remote_xid))
+		{
+			free_replica_identity_key(rientry->keydata);
+			replica_identity_delete_item(replica_identity_table, rientry);
+		}
+
+		return;
+	}
+
+	/* Record a new dependency for subsequent transactions to wait on */
+	rientry = replica_identity_insert(replica_identity_table, key,
+									  &found);
+
+	/*
+	 * Release the key built to search the entry, if the entry already exists.
+	 */
+	if (found)
+	{
+		Assert(TransactionIdIsValid(rientry->remote_xid));
+
+		elog(DEBUG1,
+			 key->kind == LOGICALREP_KEY_REPLICA_IDENTITY ?
+			 "found conflicting replica identity change from %u" :
+			 "found conflicting local unique change from %u",
+			 rientry->remote_xid);
+
+		free_replica_identity_key(key);
+
+		/*
+		 * Return if the current transaction was the last one to modify the
+		 * key.
+		 */
+		if (TransactionIdEquals(rientry->remote_xid, new_depended_xid))
+			return;
+
+		check_and_append_xid_dependency(&rientry->remote_xid, depends_on_xids);
+	}
+
+	/* Update the new depended xid into the entry */
+	rientry->remote_xid = new_depended_xid;
+}
+
 /*
  * Check for dependencies on preceding transactions that modify the same key.
  * Returns the dependent transactions in 'depends_on_xids'.
@@ -911,10 +1008,8 @@ check_and_record_ri_dependency(Oid relid, LogicalRepTupleData *original_data,
 	LogicalRepRelMapEntry *relentry;
 	LogicalRepTupleData *ridata;
 	ReplicaIdentityKey *rikey;
-	ReplicaIdentityEntry *rientry;
 	MemoryContext oldctx;
 	int			n_ri;
-	bool		found = false;
 
 	Assert(depends_on_xids);
 
@@ -993,73 +1088,121 @@ check_and_record_ri_dependency(Oid relid, LogicalRepTupleData *original_data,
 
 	rikey = palloc0_object(ReplicaIdentityKey);
 	rikey->relid = relid;
+	rikey->kind = LOGICALREP_KEY_REPLICA_IDENTITY;
 	rikey->data = ridata;
 
 	MemoryContextSwitchTo(oldctx);
+	check_and_record_key_dependency(rikey, new_depended_xid, depends_on_xids);
+}
+
+/*
+ * Mostly same as check_and_record_ri_dependency() but for local unique indexes.
+ */
+static void
+check_and_record_local_key_dependency(Oid relid,
+									  LogicalRepTupleData *original_data,
+									  TransactionId new_depended_xid,
+									  List **depends_on_xids)
+{
+	LogicalRepRelMapEntry *relentry;
+	LogicalRepTupleData *ridata;
+	ReplicaIdentityKey *rikey;
+	MemoryContext oldctx;
+
+	Assert(depends_on_xids);
+
+	/* Search for existing entry */
+	relentry = logicalrep_get_relentry(relid);
+
+	Assert(relentry);
 
 	/*
-	 * The new xid could be invalid if the transaction will be applied by the
-	 * leader itself which means all the changes will be committed before
-	 * processing next transaction. In this case, we only need to check for
-	 * dependencies on preceding transactions, there is no need to record a new
-	 * dependency for subsequent transactions to wait on.
+	 * Gather information for local indexes if not yet. We require to be in a
+	 * transaction state because system catalogs are read.
 	 */
-	if (!TransactionIdIsValid(new_depended_xid))
+	if (!relentry->local_unique_indexes_collected)
 	{
-		rientry = replica_identity_lookup(replica_identity_table, rikey);
-		free_replica_identity_key(rikey);
-
-		/* No dependency detected */
-		if (!rientry)
-			return;
+		bool		needs_start = !IsTransactionOrTransactionBlock();
 
-		elog(DEBUG1, "found conflicting replica identity change from %u",
-			 rientry->remote_xid);
+		if (needs_start)
+			StartTransactionCommand();
 
-		Assert(TransactionIdIsValid(rientry->remote_xid));
-		check_and_append_xid_dependency(&rientry->remote_xid, depends_on_xids);
+		logicalrep_rel_load(NULL, relid, AccessShareLock);
 
 		/*
-		 * Remove the entry if the remote transaction has been committed locally
-		 * and no new dependency needs to be added.
+		 * Close the transaction if we start here. We must not abort because
+		 * it would release all session-level locks, such as the stream lock,
+		 * and break the deadlock detection mechanism between LA and PA. The
+		 * outcome is the same regardless of the end status, since the
+		 * transaction did not modify any tuples.
 		 */
-		if (!TransactionIdIsValid(rientry->remote_xid))
+		if (needs_start)
+			CommitTransactionCommand();
+
+		Assert(relentry->local_unique_indexes_collected);
+	}
+
+	foreach_ptr(LogicalRepSubscriberIdx, idxinfo, relentry->local_unique_indexes)
+	{
+		int			columns = bms_num_members(idxinfo->indexkeys);
+		bool		suitable = true;
+
+		Assert(columns);
+
+		for (int i = 0; i < original_data->ncols; i++)
 		{
-			free_replica_identity_key(rientry->keydata);
-			replica_identity_delete_item(replica_identity_table, rientry);
+			if (!bms_is_member(i, idxinfo->indexkeys))
+				continue;
+
+			/*
+			 * Skip if the column is not changed.
+			 *
+			 * XXX: NULL is allowed.
+			 */
+			if (original_data->colstatus[i] == LOGICALREP_COLUMN_UNCHANGED)
+			{
+				suitable = false;
+				break;
+			}
 		}
 
-		return;
-	}
+		if (!suitable)
+			continue;
 
-	/* Record a new dependency for subsequent transactions to wait on */
-	rientry = replica_identity_insert(replica_identity_table, rikey,
-									  &found);
+		oldctx = MemoryContextSwitchTo(ApplyContext);
 
-	/*
-	 * Release the key built to search the entry, if the entry already exists.
-	 */
-	if (found)
-	{
-		Assert(TransactionIdIsValid(rientry->remote_xid));
+		/* Allocate space for replica identity values */
+		ridata = palloc0_object(LogicalRepTupleData);
+		ridata->colvalues = palloc0_array(StringInfoData, columns);
+		ridata->colstatus = palloc0_array(char, columns);
+		ridata->ncols = columns;
 
-		elog(DEBUG1, "found conflicting replica identity change from %u",
-			 rientry->remote_xid);
+		for (int i_original = 0, i_key = 0; i_original < original_data->ncols; i_original++)
+		{
+			if (!bms_is_member(i_original, idxinfo->indexkeys))
+				continue;
 
-		free_replica_identity_key(rikey);
+			if (original_data->colstatus[i_original] != LOGICALREP_COLUMN_NULL)
+			{
+				StringInfo	original_colvalue = &original_data->colvalues[i_original];
 
-		/*
-		 * Return if the current transaction was the last one to modify the
-		 * key.
-		 */
-		if (TransactionIdEquals(rientry->remote_xid, new_depended_xid))
-			return;
+				initStringInfoExt(&ridata->colvalues[i_key], original_colvalue->len + 1);
+				appendStringInfoString(&ridata->colvalues[i_key], original_colvalue->data);
+			}
 
-		check_and_append_xid_dependency(&rientry->remote_xid, depends_on_xids);
-	}
+			ridata->colstatus[i_key] = original_data->colstatus[i_original];
+			i_key++;
+		}
 
-	/* Update the new depended xid into the entry */
-	rientry->remote_xid = new_depended_xid;
+		rikey = palloc0_object(ReplicaIdentityKey);
+		rikey->relid = relid;
+		rikey->kind = LOGICALREP_KEY_LOCAL_UNIQUE;
+		rikey->data = ridata;
+
+		MemoryContextSwitchTo(oldctx);
+
+		check_and_record_key_dependency(rikey, new_depended_xid, depends_on_xids);
+	}
 }
 
 /*
@@ -1256,6 +1399,9 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			relid = logicalrep_read_insert(&change, &newtup);
 			check_and_record_ri_dependency(relid, &newtup, new_depended_xid,
 										   &depends_on_xids);
+			check_and_record_local_key_dependency(relid, &newtup,
+												  new_depended_xid,
+												  &depends_on_xids);
 			check_dependency_for_parallel_safety(relid, new_depended_xid,
 												 &depends_on_xids);
 			break;
@@ -1269,6 +1415,10 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 				check_and_record_ri_dependency(relid, &oldtup, new_depended_xid,
 											   &depends_on_xids);
 
+				check_and_record_local_key_dependency(relid, &oldtup,
+													  new_depended_xid,
+													  &depends_on_xids);
+
 				check_dependency_for_parallel_safety(relid, new_depended_xid,
 													 &depends_on_xids);
 
@@ -1284,6 +1434,9 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 
 			check_and_record_ri_dependency(relid, &newtup, new_depended_xid,
 										   &depends_on_xids);
+			check_and_record_local_key_dependency(relid, &newtup,
+												  new_depended_xid,
+												  &depends_on_xids);
 			check_dependency_for_parallel_safety(relid, new_depended_xid,
 												 &depends_on_xids);
 			break;
@@ -1292,6 +1445,9 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			relid = logicalrep_read_delete(&change, &oldtup);
 			check_and_record_ri_dependency(relid, &oldtup, new_depended_xid,
 										   &depends_on_xids);
+			check_and_record_local_key_dependency(relid, &oldtup,
+												  new_depended_xid,
+												  &depends_on_xids);
 			check_dependency_for_parallel_safety(relid, new_depended_xid,
 												 &depends_on_xids);
 			break;
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..31bbfcf1971 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -33,7 +33,6 @@
 #include "storage/procnumber.h"
 #include "utils/memutils.h"
 
-
 /*
  * One edge in the waits-for graph.
  *
diff --git a/src/include/replication/logicalrelation.h b/src/include/replication/logicalrelation.h
index b1079d3f2fa..241c4dda13c 100644
--- a/src/include/replication/logicalrelation.h
+++ b/src/include/replication/logicalrelation.h
@@ -16,6 +16,12 @@
 #include "catalog/index.h"
 #include "replication/logicalproto.h"
 
+typedef struct LogicalRepSubscriberIdx
+{
+	Oid			indexoid;	/* OID of the local key */
+	Bitmapset  *indexkeys;	/* Bitmap of key columns *on remote* */
+} LogicalRepSubscriberIdx;
+
 typedef struct LogicalRepRelMapEntry
 {
 	LogicalRepRelation remoterel;	/* key is remoterel.remoteid */
@@ -46,6 +52,10 @@ typedef struct LogicalRepRelMapEntry
 	 */
 	TransactionId last_depended_xid;
 
+	/* Local unique indexes. Used for dependency tracking */
+	List	   *local_unique_indexes;
+	bool		local_unique_indexes_collected;
+
 	/*
 	 * Whether the relation can be applied in parallel or not. It is
 	 * distinglish whether defined triggers are the immutable or not.
@@ -57,6 +67,10 @@ typedef struct LogicalRepRelMapEntry
 	 * Note that we do not check the user-defined constraints here. PostgreSQL
 	 * has already assumed that CHECK constraints' conditions are immutable and
 	 * here follows the rule.
+	 *
+	 * XXX: Additonally, this can be false if the relation has expression
+	 * indexes. Because we cannot compute the hash value for the dependency
+	 * tracking.
 	 */
 	char		parallel_safe;
 } LogicalRepRelMapEntry;
diff --git a/src/test/subscription/t/050_parallel_apply.pl b/src/test/subscription/t/050_parallel_apply.pl
index 04441ca1cd0..ae539f84639 100644
--- a/src/test/subscription/t/050_parallel_apply.pl
+++ b/src/test/subscription/t/050_parallel_apply.pl
@@ -310,4 +310,64 @@ $node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset
 
 $h->query_safe("COMMIT;");
 
+##################################################
+# Test that the dependency tracking works correctly for local unique indexes on
+# subscriber during parallel apply.
+##################################################
+
+# Truncate the data for upcoming tests
+$node_publisher->safe_psql('postgres', "TRUNCATE TABLE regress_tab;");
+$node_publisher->wait_for_catchup('regress_sub');
+
+# Define an unique index on subscriber
+$node_subscriber->safe_psql('postgres',
+    "CREATE INDEX ON regress_tab (value);");
+
+# Attach an injection_point. Parallel workers would wait before the commit
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+# Insert a tuple on publisher. Parallel worker would wait at the injection
+# point
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (1, 'would conflict');");
+
+# Wait until the parallel worker enters the injection point.
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+$offset = -s $node_subscriber->logfile;
+
+# Insert tuples on publisher again. This transaction is would wait because all
+# parallel workers wait till the previously launched worker commits.
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (2, 'would not conflict');");
+
+# Verify the parallel worker waits for the transaction
+$str = $node_subscriber->wait_for_log(qr/wait for depended xid ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /wait for depended xid ([1-9][0-9]+)/;
+
+# Insert a conflicting tuple on publisher. Leader worker would detect the conflict
+# and wait for the transaction to commit.
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (3, 'would conflict');");
+
+# Verify the parallel worker waits for the same transaction
+$node_subscriber->wait_for_log(qr/wait for depended xid $xid/, $offset);
+
+# Wakeup the parallel worker
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+# Verify the streamed transaction can be applied
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+
+# Cleanup
+$node_subscriber->safe_psql('postgres', "DROP INDEX regress_tab_value_idx;");
+$node_publisher->safe_psql('postgres', "TRUNCATE TABLE regress_tab;");
+$node_publisher->wait_for_catchup('regress_sub');
+
 done_testing();
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index db8bb4e1e43..34c9592e3e5 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -1701,6 +1701,7 @@ LogicalRepBeginData
 LogicalRepCommitData
 LogicalRepCommitPreparedTxnData
 LogicalRepCtxStruct
+LogicalRepKeyKind
 LogicalRepMsgType
 LogicalRepPartMapEntry
 LogicalRepPreparedTxnData
@@ -1710,6 +1711,7 @@ LogicalRepRelation
 LogicalRepRollbackPreparedTxnData
 LogicalRepSequenceInfo
 LogicalRepStreamAbortData
+LogicalRepSubscriberIdx
 LogicalRepTupleData
 LogicalRepTyp
 LogicalRepWorker
-- 
2.43.0



  [application/octet-stream] v19-0006-Track-dependencies-for-streamed-transactions.patch (10.1K, ../../TY4PR01MB17718A4AB7F564C8DB0D6F7DC94102@TY4PR01MB17718.jpnprd01.prod.outlook.com/6-v19-0006-Track-dependencies-for-streamed-transactions.patch)
  download | inline diff:
From 8a90e8b7c18ce6b65785530e6321065e84858c3c Mon Sep 17 00:00:00 2001
From: Zhijie Hou <[email protected]>
Date: Tue, 21 Apr 2026 15:16:59 +0800
Subject: [PATCH v19 06/10] Track dependencies for streamed transactions

This commit allows tracking dependencies of streamed transactions.

Regarding the streaming=on case, dependency tracking is enabled while applying
spooled changes from files.

In the streaming=parallel case, dependency tracking is performed when the leader
sends changes to parallel workers. Apart from non-streamed transactions, the
leader waits for parallel workers till the assigned transactions are finished at
COMMIT/PREPARE/ABORT; thus, the XID of streamed transactions is not cached as
the lastly handled one. Also, streamed transactions are not recorded as
parallelized transactions because upcoming workers do not have to wait for them.
---
 .../replication/logical/applyparallelworker.c |  8 ++-
 src/backend/replication/logical/worker.c      | 64 +++++++++++++++----
 src/include/replication/worker_internal.h     |  1 -
 src/test/subscription/t/050_parallel_apply.pl | 50 +++++++++++++++
 4 files changed, 107 insertions(+), 16 deletions(-)

diff --git a/src/backend/replication/logical/applyparallelworker.c b/src/backend/replication/logical/applyparallelworker.c
index 103db18a25b..7ced261445c 100644
--- a/src/backend/replication/logical/applyparallelworker.c
+++ b/src/backend/replication/logical/applyparallelworker.c
@@ -165,7 +165,9 @@
  * key) as another ongoing transaction (see handle_dependency_on_change for
  * details). If so, the leader sends a list of dependent transaction IDs to the
  * parallel worker, indicating that the parallel apply worker must wait for
- * these transactions to commit before proceeding.
+ * these transactions to commit before proceeding. If transactions are streamed
+ * but leader deciedes not to assign parallel apply workers, dependencies are
+ * verified when the transaction is committed.
  *
  * Tracking dependencies is necessary even when commit order is preserved.
  * Consider two transactions: TX-1 (INSERT row 1) and TX-2 (DELETE row 1). If
@@ -1855,6 +1857,10 @@ pa_stream_abort(LogicalRepStreamAbortData *abort_data)
 	TransactionId xid = abort_data->xid;
 	TransactionId subxid = abort_data->subxid;
 
+	/* Streamed transactions won't be registered */
+	Assert(!dshash_find(parallelized_txns, &xid, false) &&
+		   !dshash_find(parallelized_txns, &subxid, false));
+
 	/*
 	 * Update origin state so we can restart streaming from correct position
 	 * in case of crash.
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 5535841c409..ff1c7142f18 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -1290,8 +1290,6 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 	 */
 	if (winfo == NULL)
 	{
-		Assert(!TransactionIdIsValid(new_depended_xid));
-
 		foreach_xid(xid, depends_on_xids)
 			pa_wait_for_depended_transaction(xid);
 	}
@@ -1498,13 +1496,22 @@ handle_streamed_transaction(LogicalRepMsgType action, StringInfo s)
 		case TRANS_LEADER_SEND_TO_PARALLEL:
 			Assert(winfo);
 
+			/*
+			 * Pass InvalidTransactionId to skip dependency recording for this
+			 * change. Streaming transactions are assumed not to conflict with
+			 * other transactions, so subsequent transactions do not need to
+			 * wait for them to finish.
+			 */
+			handle_dependency_on_change(action, s, InvalidTransactionId, winfo);
+
 			/*
 			 * XXX The publisher side doesn't always send relation/type update
 			 * messages after the streaming transaction, so also update the
 			 * relation/type in leader apply worker. See function
 			 * cleanup_rel_sync_cache.
 			 */
-			if (pa_send_data(winfo, s->len, s->data))
+			if (!winfo->serialize_changes &&
+				pa_send_data(winfo, s->len, s->data))
 				return (action != LOGICAL_REP_MSG_RELATION &&
 						action != LOGICAL_REP_MSG_TYPE);
 
@@ -2635,6 +2642,13 @@ apply_handle_stream_prepare(StringInfo s)
 			apply_spooled_messages(MyLogicalRepWorker->stream_fileset,
 								   prepare_data.xid, prepare_data.prepare_lsn);
 
+			/* Wait until the last transaction finishes */
+			if (TransactionIdIsValid(last_parallelized_remote_xid))
+			{
+				pa_wait_for_depended_transaction(last_parallelized_remote_xid);
+				last_parallelized_remote_xid = InvalidTransactionId;
+			}
+
 			/* Mark the transaction as prepared. */
 			apply_handle_prepare_internal(&prepare_data);
 
@@ -2658,6 +2672,12 @@ apply_handle_stream_prepare(StringInfo s)
 		case TRANS_LEADER_SEND_TO_PARALLEL:
 			Assert(winfo);
 
+			/*
+			 * Build a dependency between this transaction and the lastly
+			 * committed transaction to preserve the commit order.
+			 */
+			build_dependency_with_last_committed_txn(winfo);
+
 			if (pa_send_data(winfo, s->len, s->data))
 			{
 				/* Finish processing the streaming transaction. */
@@ -2724,6 +2744,11 @@ apply_handle_stream_prepare(StringInfo s)
 
 	pgstat_report_stat(false);
 
+	/*
+	 * No need to update the last_parallelized_remote_xid here because leader
+	 * worker always wait until streamed transactions finish.
+	 */
+
 	/*
 	 * Process any tables that are being synchronized in parallel, as well as
 	 * any newly added tables or sequences.
@@ -2837,17 +2862,8 @@ apply_handle_stream_start(StringInfo s)
 
 	set_apply_error_context_xact(stream_xid, InvalidXLogRecPtr);
 
-	/*
-	 * Try to allocate a worker for the streaming transaction.
-	 *
-	 * TODO: Support assigning streaming transactions to parallel apply workers
-	 * even when non-streaming transactions are running and better dependency
-	 * handling between streaming and non-streaming transactions.
-	 */
-	if (first_segment &&
-		am_leader_apply_worker() &&
-		(!TransactionIdIsValid(last_parallelized_remote_xid) ||
-		 pa_transaction_committed(last_parallelized_remote_xid)))
+	/* Try to allocate a worker for the streaming transaction. */
+	if (first_segment)
 		pa_allocate_worker(stream_xid, true);
 
 	apply_action = get_transaction_apply_action(stream_xid, &winfo);
@@ -3511,6 +3527,13 @@ apply_handle_stream_commit(StringInfo s)
 			apply_spooled_messages(MyLogicalRepWorker->stream_fileset, xid,
 								   commit_data.commit_lsn);
 
+			/* Wait until the last transaction finishes */
+			if (TransactionIdIsValid(last_parallelized_remote_xid))
+			{
+				pa_wait_for_depended_transaction(last_parallelized_remote_xid);
+				last_parallelized_remote_xid = InvalidTransactionId;
+			}
+
 			apply_handle_commit_internal(&commit_data);
 
 			/* Unlink the files with serialized changes and subxact info. */
@@ -3522,6 +3545,19 @@ apply_handle_stream_commit(StringInfo s)
 		case TRANS_LEADER_SEND_TO_PARALLEL:
 			Assert(winfo);
 
+			/*
+			 * Apart from non-streaming case, no need to mark this transaction
+			 * as parallelized. Because the leader waits until the streamed
+			 * transaction is committed thus commit ordering is always
+			 * preserved.
+			 */
+
+			/*
+			 * Build a dependency between this transaction and the lastly
+			 * committed transaction to preserve the commit order.
+			 */
+			build_dependency_with_last_committed_txn(winfo);
+
 			if (pa_send_data(winfo, s->len, s->data))
 			{
 				/* Finish processing the streaming transaction. */
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index 02a165ccc0d..3dde899f465 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -370,7 +370,6 @@ extern void pa_switch_to_partial_serialize(ParallelApplyWorkerInfo *winfo,
 extern void pa_set_xact_state(ParallelApplyWorkerShared *wshared,
 							  ParallelTransState xact_state);
 extern void pa_set_stream_apply_worker(ParallelApplyWorkerInfo *winfo);
-
 extern void pa_start_subtrans(TransactionId current_xid,
 							  TransactionId top_xid);
 extern void pa_reset_subtrans(void);
diff --git a/src/test/subscription/t/050_parallel_apply.pl b/src/test/subscription/t/050_parallel_apply.pl
index a7bbc8e59c9..04441ca1cd0 100644
--- a/src/test/subscription/t/050_parallel_apply.pl
+++ b/src/test/subscription/t/050_parallel_apply.pl
@@ -260,4 +260,54 @@ $node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset
 $node_publisher->safe_psql('postgres', "COMMIT PREPARED 'regress_prepare';");
 $node_publisher->wait_for_catchup('regress_sub');
 
+##################################################
+# Test that streaming transactions respect commit order preservation when
+# non-streaming transactions are being applied in parallel workers.
+##################################################
+
+$node_publisher->append_conf('postgresql.conf',
+   "logical_decoding_work_mem = 64kB");
+$node_publisher->reload;
+# Run a query to make sure that the reload has taken effect.
+$node_publisher->safe_psql('postgres', "SELECT 1");
+
+# Attach the injection_point again
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(71, 80), 'test');");
+
+# Wait until the parallel worker enters the injection point.
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+# Run a transaction which would be streamed
+my $h = $node_publisher->background_psql('postgres', on_error_stop => 0);
+
+$offset = -s $node_subscriber->logfile;
+
+$h->query_safe(
+	q{
+BEGIN;
+UPDATE regress_tab SET value = 'streamed-updated' WHERE id BETWEEN 71 AND 80;
+INSERT INTO regress_tab VALUES (generate_series(100, 5100), 'streamed');
+});
+
+# Verify the parallel worker waits for the transaction
+$str = $node_subscriber->wait_for_log(qr/wait for depended xid ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /wait for depended xid ([1-9][0-9]+)/;
+
+# Wakeup the parallel worker
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+# Verify the streamed transaction can be applied
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+
+$h->query_safe("COMMIT;");
+
 done_testing();
-- 
2.43.0



  [application/octet-stream] v19-0007-Wait-applying-transaction-if-one-of-user-defined.patch (11.9K, ../../TY4PR01MB17718A4AB7F564C8DB0D6F7DC94102@TY4PR01MB17718.jpnprd01.prod.outlook.com/7-v19-0007-Wait-applying-transaction-if-one-of-user-defined.patch)
  download | inline diff:
From 54da67ce47be72d81b2b7fcbd2cf8370b4853927 Mon Sep 17 00:00:00 2001
From: Zhijie Hou <[email protected]>
Date: Thu, 4 Jun 2026 10:26:14 +0800
Subject: [PATCH v19 07/10] Wait applying transaction if one of user-defined
 triggers is immutable

Since many parallel workers apply transactions, triggers for relations can also
be fired in parallel, which may obtain unexpected results. To make it safe,
parallel apply workers wait for the previously dispatched transaction before
applying changes to the relation that has mutable triggers.
---
 src/backend/replication/logical/relation.c | 123 ++++++++++++++++++---
 src/backend/replication/logical/worker.c   |  66 +++++++++++
 src/include/replication/logicalrelation.h  |  20 ++++
 3 files changed, 195 insertions(+), 14 deletions(-)

diff --git a/src/backend/replication/logical/relation.c b/src/backend/replication/logical/relation.c
index e1ce183dfd3..eeb85f8cc5d 100644
--- a/src/backend/replication/logical/relation.c
+++ b/src/backend/replication/logical/relation.c
@@ -21,7 +21,9 @@
 #include "access/genam.h"
 #include "access/table.h"
 #include "catalog/namespace.h"
+#include "catalog/pg_proc.h"
 #include "catalog/pg_subscription_rel.h"
+#include "commands/trigger.h"
 #include "executor/executor.h"
 #include "nodes/makefuncs.h"
 #include "replication/logicalrelation.h"
@@ -160,6 +162,10 @@ logicalrep_relmap_free_entry(LogicalRepRelMapEntry *entry)
  *
  * Called when new relation mapping is sent by the publisher to update
  * our expected view of incoming data from said publisher.
+ *
+ * Note that we do not check the user-defined constraints here. PostgreSQL has
+ * already assumed that CHECK constraints' conditions are immutable and here
+ * follows the rule.
  */
 void
 logicalrep_relmap_update(LogicalRepRelation *remoterel)
@@ -209,6 +215,8 @@ logicalrep_relmap_update(LogicalRepRelation *remoterel)
 		(remoterel->relkind == 0) ? RELKIND_RELATION : remoterel->relkind;
 
 	entry->remoterel.attkeys = bms_copy(remoterel->attkeys);
+
+	entry->parallel_safe = LOGICALREP_PARALLEL_UNKNOWN;
 	MemoryContextSwitchTo(oldctx);
 }
 
@@ -354,27 +362,79 @@ logicalrep_rel_mark_updatable(LogicalRepRelMapEntry *entry)
 }
 
 /*
- * Open the local relation associated with the remote one.
+ * Check all local triggers for the relation to see the parallelizability.
  *
- * Rebuilds the Relcache mapping if it was invalidated by local DDL.
+ * We regard relations as applicable in parallel if all triggers are immutable.
+ * Result is directly set to LogicalRepRelMapEntry::parallel_safe.
  */
-LogicalRepRelMapEntry *
-logicalrep_rel_open(LogicalRepRelId remoteid, LOCKMODE lockmode)
+static void
+check_defined_triggers(LogicalRepRelMapEntry *entry)
+{
+	TriggerDesc *trigdesc = entry->localrel->trigdesc;
+
+	/* Quick exit if triffer is not defined */
+	if (trigdesc == NULL)
+	{
+		entry->parallel_safe = LOGICALREP_PARALLEL_SAFE;
+		return;
+	}
+
+	/* Seek triggers one by one to see the volatility */
+	for (int i = 0; i < trigdesc->numtriggers; i++)
+	{
+		Trigger    *trigger = &trigdesc->triggers[i];
+
+		Assert(OidIsValid(trigger->tgfoid));
+
+		/* Skip if the trigger is not enabled for logical replication */
+		if (trigger->tgenabled == TRIGGER_DISABLED ||
+			trigger->tgenabled == TRIGGER_FIRES_ON_ORIGIN)
+			continue;
+
+		/* Check the volatility of the trigger. Exit if it is not immutable */
+		if (func_volatile(trigger->tgfoid) != PROVOLATILE_IMMUTABLE)
+		{
+			entry->parallel_safe = LOGICALREP_PARALLEL_RESTRICTED;
+			return;
+		}
+	}
+
+	/* All triggers are immutable, set as parallel safe */
+	entry->parallel_safe = LOGICALREP_PARALLEL_SAFE;
+}
+
+/*
+ * Actual workhorse for logicalrep_rel_open().
+ *
+ * Caller must specify *either* entry or key. If the entry is specified, its
+ * attributes are filled and returned. The logical relation is kept opening.
+ * If the key is given, the corresponding entry is first searched in the hash
+ * table and processed as in the above case. At the end, logical replication is
+ * closed.
+  */
+void
+logicalrep_rel_load(LogicalRepRelMapEntry *entry, LogicalRepRelId remoteid,
+					LOCKMODE lockmode)
 {
-	LogicalRepRelMapEntry *entry;
-	bool		found;
 	LogicalRepRelation *remoterel;
 
-	if (LogicalRepRelMap == NULL)
-		logicalrep_relmap_init();
+	Assert((entry && !remoteid) || (!entry && remoteid));
 
-	/* Search for existing entry. */
-	entry = hash_search(LogicalRepRelMap, &remoteid,
-						HASH_FIND, &found);
+	if (!entry)
+	{
+		bool		found;
 
-	if (!found)
-		elog(ERROR, "no relation map entry for remote relation ID %u",
-			 remoteid);
+		if (LogicalRepRelMap == NULL)
+			logicalrep_relmap_init();
+
+		/* Search for existing entry. */
+		entry = hash_search(LogicalRepRelMap, &remoteid,
+							HASH_FIND, &found);
+
+		if (!found)
+			elog(ERROR, "no relation map entry for remote relation ID %u",
+				 remoteid);
+	}
 
 	remoterel = &entry->remoterel;
 
@@ -500,6 +560,13 @@ logicalrep_rel_open(LogicalRepRelId remoteid, LOCKMODE lockmode)
 		entry->localindexoid = FindLogicalRepLocalIndex(entry->localrel, remoterel,
 														entry->attrmap);
 
+		/*
+		 * Leader must also collect all local unique indexes for dependency
+		 * tracking.
+		 */
+		if (am_leader_apply_worker())
+			check_defined_triggers(entry);
+
 		entry->localrelvalid = true;
 	}
 
@@ -508,6 +575,34 @@ logicalrep_rel_open(LogicalRepRelId remoteid, LOCKMODE lockmode)
 											   entry->localreloid,
 											   &entry->statelsn);
 
+	if (remoteid)
+		logicalrep_rel_close(entry, lockmode);
+}
+
+/*
+ * Open the local relation associated with the remote one.
+ *
+ * Rebuilds the Relcache mapping if it was invalidated by local DDL.
+ */
+LogicalRepRelMapEntry *
+logicalrep_rel_open(LogicalRepRelId remoteid, LOCKMODE lockmode)
+{
+	LogicalRepRelMapEntry *entry;
+	bool		found;
+
+	if (LogicalRepRelMap == NULL)
+		logicalrep_relmap_init();
+
+	/* Search for existing entry. */
+	entry = hash_search(LogicalRepRelMap, &remoteid,
+						HASH_FIND, &found);
+
+	if (!found)
+		elog(ERROR, "no relation map entry for remote relation ID %u",
+			 remoteid);
+
+	logicalrep_rel_load(entry, 0, lockmode);
+
 	return entry;
 }
 
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index ff1c7142f18..de665b8d4b3 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -1138,6 +1138,58 @@ check_and_record_rel_dependency(LogicalRepRelId relid,
 		relentry->last_depended_xid = new_depended_xid;
 }
 
+/*
+ * Check the parallelizability of applying changes for the relation.
+ * Append the lastly dispatched transaction in in 'depends_on_xids' if the
+ * relation is parallel unsafe.
+ */
+static void
+check_dependency_for_parallel_safety(LogicalRepRelId relid,
+									 TransactionId new_depended_xid,
+									 List **depends_on_xids)
+{
+	LogicalRepRelMapEntry *relentry;
+
+	/* Quick exit if no transactions have been dispatched */
+	if (!TransactionIdIsValid(last_parallelized_remote_xid))
+		return;
+
+	relentry = logicalrep_get_relentry(relid);
+
+	/*
+	 * Gather information for local triggres if not yet. We require to be in a
+	 * transaction state because system catalogs are read.
+	 */
+	if (relentry->parallel_safe == LOGICALREP_PARALLEL_UNKNOWN)
+	{
+		bool		needs_start = !IsTransactionOrTransactionBlock();
+
+		if (needs_start)
+			StartTransactionCommand();
+
+		logicalrep_rel_load(NULL, relid, AccessShareLock);
+
+		/*
+		 * Close the transaction if we start here. We must not abort because
+		 * it would release all session-level locks, such as the stream lock,
+		 * and break the deadlock detection mechanism between LA and PA. The
+		 * outcome is the same regardless of the end status, since the
+		 * transaction did not modify any tuples.
+		 */
+		if (needs_start)
+			CommitTransactionCommand();
+
+		Assert(relentry->parallel_safe != LOGICALREP_PARALLEL_UNKNOWN);
+	}
+
+	/* Do nothing for parallel safe relations */
+	if (relentry->parallel_safe == LOGICALREP_PARALLEL_SAFE)
+		return;
+
+	check_and_append_xid_dependency(&last_parallelized_remote_xid,
+									depends_on_xids);
+}
+
 /*
  * Check dependencies related to the current change by determining if the
  * modification impacts the same row or table as another ongoing transaction. If
@@ -1204,6 +1256,8 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			relid = logicalrep_read_insert(&change, &newtup);
 			check_and_record_ri_dependency(relid, &newtup, new_depended_xid,
 										   &depends_on_xids);
+			check_dependency_for_parallel_safety(relid, new_depended_xid,
+												 &depends_on_xids);
 			break;
 
 		case LOGICAL_REP_MSG_UPDATE:
@@ -1215,6 +1269,9 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 				check_and_record_ri_dependency(relid, &oldtup, new_depended_xid,
 											   &depends_on_xids);
 
+				check_dependency_for_parallel_safety(relid, new_depended_xid,
+													 &depends_on_xids);
+
 				/*
 				 * Copy unchanged column values from the old tuple to the new
 				 * tuple for replica identity dependency checking. See
@@ -1227,12 +1284,16 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 
 			check_and_record_ri_dependency(relid, &newtup, new_depended_xid,
 										   &depends_on_xids);
+			check_dependency_for_parallel_safety(relid, new_depended_xid,
+												 &depends_on_xids);
 			break;
 
 		case LOGICAL_REP_MSG_DELETE:
 			relid = logicalrep_read_delete(&change, &oldtup);
 			check_and_record_ri_dependency(relid, &oldtup, new_depended_xid,
 										   &depends_on_xids);
+			check_dependency_for_parallel_safety(relid, new_depended_xid,
+												 &depends_on_xids);
 			break;
 
 		case LOGICAL_REP_MSG_TRUNCATE:
@@ -1245,9 +1306,14 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			 * modified the same table.
 			 */
 			foreach_int(truncated_relid, remote_relids)
+			{
 				check_and_record_rel_dependency(truncated_relid,
 												new_depended_xid,
 												&depends_on_xids);
+				check_dependency_for_parallel_safety(truncated_relid,
+													 new_depended_xid,
+													 &depends_on_xids);
+			}
 
 			break;
 
diff --git a/src/include/replication/logicalrelation.h b/src/include/replication/logicalrelation.h
index cd852337165..b1079d3f2fa 100644
--- a/src/include/replication/logicalrelation.h
+++ b/src/include/replication/logicalrelation.h
@@ -45,6 +45,20 @@ typedef struct LogicalRepRelMapEntry
 	 * applying (see check_dependency_on_rel).
 	 */
 	TransactionId last_depended_xid;
+
+	/*
+	 * Whether the relation can be applied in parallel or not. It is
+	 * distinglish whether defined triggers are the immutable or not.
+	 *
+	 * Theoretically, we can determine the parallelizability for each type of
+	 * replication messages, INSERT/UPDATE/DELETE/TRUNCATE. But it is not done
+	 * yet to reduce the number of attributes.
+	 *
+	 * Note that we do not check the user-defined constraints here. PostgreSQL
+	 * has already assumed that CHECK constraints' conditions are immutable and
+	 * here follows the rule.
+	 */
+	char		parallel_safe;
 } LogicalRepRelMapEntry;
 
 extern void logicalrep_relmap_update(LogicalRepRelation *remoterel);
@@ -52,6 +66,8 @@ extern void logicalrep_partmap_reset_relmap(LogicalRepRelation *remoterel);
 
 extern LogicalRepRelMapEntry *logicalrep_rel_open(LogicalRepRelId remoteid,
 												  LOCKMODE lockmode);
+extern void logicalrep_rel_load(LogicalRepRelMapEntry *entry,
+								LogicalRepRelId remoteid, LOCKMODE lockmode);
 extern LogicalRepRelMapEntry *logicalrep_partition_open(LogicalRepRelMapEntry *root,
 														Relation partrel, AttrMap *map);
 extern void logicalrep_rel_close(LogicalRepRelMapEntry *rel,
@@ -62,4 +78,8 @@ extern int	logicalrep_get_num_rels(void);
 extern void logicalrep_write_all_rels(StringInfo out);
 extern LogicalRepRelMapEntry *logicalrep_get_relentry(LogicalRepRelId remoteid);
 
+#define LOGICALREP_PARALLEL_SAFE		's'
+#define LOGICALREP_PARALLEL_RESTRICTED	'r'
+#define LOGICALREP_PARALLEL_UNKNOWN		'u'
+
 #endif							/* LOGICALRELATION_H */
-- 
2.43.0



  [application/octet-stream] v19-0005-support-2PC.patch (13.4K, ../../TY4PR01MB17718A4AB7F564C8DB0D6F7DC94102@TY4PR01MB17718.jpnprd01.prod.outlook.com/8-v19-0005-support-2PC.patch)
  download | inline diff:
From 1e1ec4d6e43ef8ce1a4bf9aade573b681a590fce Mon Sep 17 00:00:00 2001
From: Zhijie Hou <[email protected]>
Date: Thu, 4 Jun 2026 13:15:14 +0800
Subject: [PATCH v19 05/10] support 2PC

This patch allows the PREPARE transaction to be applied in parallel. Parallel
apply workers are assigned to a transaction when BEGIN_PREPARE is received. This
part and the dependency-waiting mechanism are the same as a normal transaction.

A parallel worker can be freed after it handles a PREPARE message. The prepared
transaction can be deleted from parallelized_txns at that time; the upcoming
transactions will wait until then.

The leader apply worker resolves COMMIT PREPARED/ROLLBACK PREPARED. Since it can
be serialized automatically, it does not add the transaction to parallelized_txns.
---
 src/backend/replication/logical/worker.c      | 218 +++++++++++++++---
 src/test/subscription/t/050_parallel_apply.pl |  64 ++++-
 2 files changed, 253 insertions(+), 29 deletions(-)

diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index faf7a50bf25..5535841c409 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -806,6 +806,14 @@ delete_replica_identity_entries_for_txns(List *committed_xids)
 	if (!committed_xids)
 		return;
 
+	/*
+	 * Skip if the replica_identity_table is not initialized yet. This can
+	 * happen if the empty transaction was replicated and a parallel apply
+	 * worker was launched. See comments in apply_handle_prepare().
+	 */
+	if (!replica_identity_table)
+		return;
+
 	replica_identity_start_iterate(replica_identity_table, &i);
 	while ((rientry = replica_identity_iterate(replica_identity_table, &i)) != NULL)
 	{
@@ -2184,6 +2192,8 @@ static void
 apply_handle_begin_prepare(StringInfo s)
 {
 	LogicalRepPreparedTxnData begin_data;
+	ParallelApplyWorkerInfo *winfo;
+	TransApplyAction apply_action;
 
 	/* Tablesync should never receive prepare. */
 	if (am_tablesync_worker())
@@ -2195,12 +2205,54 @@ apply_handle_begin_prepare(StringInfo s)
 	Assert(!TransactionIdIsValid(stream_xid));
 
 	logicalrep_read_begin_prepare(s, &begin_data);
-	set_apply_error_context_xact(begin_data.xid, begin_data.prepare_lsn);
+
+	remote_xid = begin_data.xid;
+
+	set_apply_error_context_xact(remote_xid, begin_data.prepare_lsn);
 
 	remote_final_lsn = begin_data.prepare_lsn;
 
 	maybe_start_skipping_changes(begin_data.prepare_lsn);
 
+	pa_allocate_worker(remote_xid, false);
+
+	apply_action = get_transaction_apply_action(remote_xid, &winfo);
+
+	elog(DEBUG1, "new remote_xid %u", remote_xid);
+	switch (apply_action)
+	{
+		case TRANS_LEADER_APPLY:
+			break;
+
+		case TRANS_LEADER_SEND_TO_PARALLEL:
+			Assert(winfo);
+
+			if (pa_send_data(winfo, s->len, s->data))
+			{
+				pa_set_stream_apply_worker(winfo);
+				break;
+			}
+
+			/*
+			 * TODO: Support switching to PARTIAL_SERIALIZE mode when the send
+			 * buffer becomes full.
+			 */
+			ereport(ERROR,
+					errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+					errmsg("could not send data to the logical replication parallel apply worker"));
+			break;
+
+		case TRANS_PARALLEL_APPLY:
+			/* Hold the lock until the end of the transaction. */
+			pa_lock_transaction(MyParallelShared->xid, AccessExclusiveLock);
+			pa_set_xact_state(MyParallelShared, PARALLEL_TRANS_STARTED);
+			break;
+
+		default:
+			elog(ERROR, "unexpected apply action: %d", (int) apply_action);
+			break;
+	}
+
 	in_remote_transaction = true;
 
 	pgstat_report_activity(STATE_RUNNING, NULL);
@@ -2250,6 +2302,8 @@ static void
 apply_handle_prepare(StringInfo s)
 {
 	LogicalRepPreparedTxnData prepare_data;
+	ParallelApplyWorkerInfo *winfo;
+	TransApplyAction apply_action;
 
 	logicalrep_read_prepare(s, &prepare_data);
 
@@ -2260,36 +2314,131 @@ apply_handle_prepare(StringInfo s)
 								 LSN_FORMAT_ARGS(prepare_data.prepare_lsn),
 								 LSN_FORMAT_ARGS(remote_final_lsn))));
 
-	/*
-	 * Unlike commit, here, we always prepare the transaction even though no
-	 * change has happened in this transaction or all changes are skipped. It
-	 * is done this way because at commit prepared time, we won't know whether
-	 * we have skipped preparing a transaction because of those reasons.
-	 *
-	 * XXX, We can optimize such that at commit prepared time, we first check
-	 * whether we have prepared the transaction or not but that doesn't seem
-	 * worthwhile because such cases shouldn't be common.
-	 */
-	begin_replication_step();
+	apply_action = get_transaction_apply_action(remote_xid, &winfo);
 
-	apply_handle_prepare_internal(&prepare_data);
+	switch (apply_action)
+	{
+		case TRANS_LEADER_APPLY:
 
-	end_replication_step();
-	CommitTransactionCommand();
-	pgstat_report_stat(false);
+			/*
+			 * Unlike commit, here, we always prepare the transaction even
+			 * though no change has happened in this transaction or all
+			 * changes are skipped. It is done this way because at commit
+			 * prepared time, we won't know whether we have skipped preparing
+			 * a transaction because of those reasons.
+			 *
+			 * XXX, We can optimize such that at commit prepared time, we
+			 * first check whether we have prepared the transaction or not but
+			 * that doesn't seem worthwhile because such cases shouldn't be
+			 * common.
+			 */
+			begin_replication_step();
 
-	/*
-	 * It is okay not to set the local_end LSN for the prepare because we
-	 * always flush the prepare record. So, we can send the acknowledgment of
-	 * the remote_end LSN as soon as prepare is finished.
-	 *
-	 * XXX For the sake of consistency with commit, we could have set it with
-	 * the LSN of prepare but as of now we don't track that value similar to
-	 * XactLastCommitEnd, and adding it for this purpose doesn't seems worth
-	 * it.
-	 */
-	store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr,
-						 InvalidTransactionId);
+			/* Wait until the last transaction finishes */
+			if (TransactionIdIsValid(last_parallelized_remote_xid))
+			{
+				pa_wait_for_depended_transaction(last_parallelized_remote_xid);
+				last_parallelized_remote_xid = InvalidTransactionId;
+			}
+
+			apply_handle_prepare_internal(&prepare_data);
+
+			end_replication_step();
+			CommitTransactionCommand();
+			pgstat_report_stat(false);
+
+			/*
+			 * It is okay not to set the local_end LSN for the prepare because
+			 * we always flush the prepare record. So, we can send the
+			 * acknowledgment of the remote_end LSN as soon as prepare is
+			 * finished.
+			 *
+			 * XXX For the sake of consistency with commit, we could have set
+			 * it with the LSN of prepare but as of now we don't track that
+			 * value similar to XactLastCommitEnd, and adding it for this
+			 * purpose doesn't seems worth it.
+			 */
+			store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr,
+								 InvalidTransactionId);
+
+			break;
+
+		case TRANS_LEADER_SEND_TO_PARALLEL:
+			Assert(winfo);
+
+			/*
+			 * Mark this transaction as parallelized. This ensures that
+			 * upcoming transactions wait until this transaction is committed.
+			 */
+			pa_add_parallelized_transaction(remote_xid);
+
+			/*
+			 * Build a dependency between this transaction and the lastly
+			 * committed transaction to preserve the commit order.
+			 */
+			build_dependency_with_last_committed_txn(winfo);
+
+			if (pa_send_data(winfo, s->len, s->data))
+			{
+				/* Cache the remote_xid */
+				last_parallelized_remote_xid = remote_xid;
+
+				/* Finish processing the transaction. */
+				pa_xact_finish(winfo, prepare_data.end_lsn);
+				break;
+			}
+
+			/*
+			 * TODO: Support switching to PARTIAL_SERIALIZE mode when the send
+			 * buffer becomes full.
+			 */
+			ereport(ERROR,
+					errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+					errmsg("could not send data to the logical replication parallel apply worker"));
+			break;
+
+		case TRANS_PARALLEL_APPLY:
+
+			/*
+			 * If the parallel apply worker is applying spooled messages then
+			 * close the file before committing.
+			 */
+			if (stream_fd)
+				stream_close_file();
+
+			begin_replication_step();
+
+			INJECTION_POINT("parallel-worker-before-prepare", NULL);
+
+			/* Mark the transaction as prepared. */
+			apply_handle_prepare_internal(&prepare_data);
+
+			end_replication_step();
+
+			CommitTransactionCommand();
+			pgstat_report_stat(false);
+
+			store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr,
+								 InvalidTransactionId);
+
+			/*
+			 * It is okay not to set the local_end LSN for the prepare because
+			 * we always flush the prepare record. See apply_handle_prepare.
+			 */
+			MyParallelShared->last_commit_end = InvalidXLogRecPtr;
+			pa_commit_transaction();
+
+			pa_unlock_transaction(MyParallelShared->xid, AccessExclusiveLock);
+
+			pa_reset_subtrans();
+			break;
+
+		default:
+			elog(ERROR, "unexpected apply action: %d", (int) apply_action);
+			break;
+	}
+
+	remote_xid = InvalidTransactionId;
 
 	in_remote_transaction = false;
 
@@ -2337,6 +2486,12 @@ apply_handle_commit_prepared(StringInfo s)
 	/* There is no transaction when COMMIT PREPARED is called */
 	begin_replication_step();
 
+	if (TransactionIdIsValid(last_parallelized_remote_xid))
+	{
+		pa_wait_for_depended_transaction(last_parallelized_remote_xid);
+		last_parallelized_remote_xid = InvalidTransactionId;
+	}
+
 	/*
 	 * Update origin state so we can restart streaming from correct position
 	 * in case of crash.
@@ -2405,6 +2560,13 @@ apply_handle_rollback_prepared(StringInfo s)
 
 		/* There is no transaction when ABORT/ROLLBACK PREPARED is called */
 		begin_replication_step();
+
+		if (TransactionIdIsValid(last_parallelized_remote_xid))
+		{
+			pa_wait_for_depended_transaction(last_parallelized_remote_xid);
+			last_parallelized_remote_xid = InvalidTransactionId;
+		}
+
 		FinishPreparedTransaction(gid, false);
 		end_replication_step();
 		CommitTransactionCommand();
diff --git a/src/test/subscription/t/050_parallel_apply.pl b/src/test/subscription/t/050_parallel_apply.pl
index 14121b23f23..a7bbc8e59c9 100644
--- a/src/test/subscription/t/050_parallel_apply.pl
+++ b/src/test/subscription/t/050_parallel_apply.pl
@@ -19,6 +19,8 @@ if ($ENV{enable_injection_points} ne 'yes')
 # Initialize publisher node
 my $node_publisher = PostgreSQL::Test::Cluster->new('publisher');
 $node_publisher->init(allows_streaming => 'logical');
+$node_publisher->append_conf('postgresql.conf',
+    "max_prepared_transactions = 10");
 $node_publisher->start;
 
 # Create tables and insert initial data
@@ -44,7 +46,8 @@ my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
 $node_subscriber->init;
 $node_subscriber->append_conf('postgresql.conf', "log_min_messages = debug1");
 $node_subscriber->append_conf('postgresql.conf',
-	"max_logical_replication_workers = 10");
+	"max_logical_replication_workers = 10
+    max_prepared_transactions = 10");
 $node_subscriber->start;
 
 # Check if the extension injection_points is available, as it may be
@@ -198,4 +201,63 @@ $result =
   $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM tab_toast");
 is ($result, 0, 'changes are replicated to subscriber');
 
+##################################################
+# Test that the prepared transaction can be applied in a parallel apply worker,
+# and that the commit order preservation work correctly.
+##################################################
+
+$node_subscriber->safe_psql('postgres',
+    "ALTER SUBSCRIPTION regress_sub DISABLE;");
+$node_subscriber->poll_query_until('postgres',
+	"SELECT count(*) = 0 FROM pg_stat_activity WHERE backend_type = 'logical replication apply worker'"
+);
+$node_subscriber->safe_psql(
+	'postgres', "
+    ALTER SUBSCRIPTION regress_sub SET (two_phase = on);
+    ALTER SUBSCRIPTION regress_sub ENABLE;");
+
+$result = $node_subscriber->safe_psql('postgres',
+    "SELECT count(1) FROM pg_stat_activity WHERE backend_type = 'logical replication parallel worker'");
+is($result, '0', "no parallel apply workers exist after restart");
+
+# Attach an injection_point. Parallel workers would wait before the prepare
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-prepare','wait');"
+);
+
+# PREPARE a transaction on publisher. It would be handled by a parallel apply
+# worker.
+$node_publisher->safe_psql('postgres', qq[
+    BEGIN;
+    INSERT INTO regress_tab VALUES (generate_series(51, 60), 'prepare');
+    PREPARE TRANSACTION 'regress_prepare';
+]);
+
+# Wait until the parallel worker enters the injection point.
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-prepare');
+
+$offset = -s $node_subscriber->logfile;
+
+# Insert tuples on publisher again. This transaction waits for the prepared
+# transaction
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(61, 70), 'test');");
+
+# Verify the parallel worker waits for the transaction
+$str = $node_subscriber->wait_for_log(qr/wait for depended xid ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /wait for depended xid ([1-9][0-9]+)/;
+
+# Wakeup the parallel worker
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-prepare');
+    SELECT injection_points_wakeup('parallel-worker-before-prepare');
+]);
+
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+
+# COMMIT the prepared transaction. It is always handled by the leader
+$node_publisher->safe_psql('postgres', "COMMIT PREPARED 'regress_prepare';");
+$node_publisher->wait_for_catchup('regress_sub');
+
 done_testing();
-- 
2.43.0



  [application/octet-stream] v19-0004-Parallel-apply-non-streaming-transactions.patch (61.6K, ../../TY4PR01MB17718A4AB7F564C8DB0D6F7DC94102@TY4PR01MB17718.jpnprd01.prod.outlook.com/9-v19-0004-Parallel-apply-non-streaming-transactions.patch)
  download | inline diff:
From 960841845f58c6b1e3dc93b6337d2e7ca285918f Mon Sep 17 00:00:00 2001
From: Zhijie Hou <[email protected]>
Date: Thu, 4 Jun 2026 10:20:44 +0800
Subject: [PATCH v19 04/10] Parallel apply non-streaming transactions

--
Basic design
--

The leader worker assigns each non-streaming transaction to a parallel apply
worker. Before dispatching changes to a parallel worker, the leader verifies if
the current modification affects the same row (identitied by replica identity
key) as another ongoing transaction. If so, the leader sends a list of dependent
transaction IDs to the parallel worker, indicating that the parallel apply
worker must wait for these transactions to commit before proceeding.

The leader preserves publisher commit order for all transactions by instructing
the parallel worker to wait for the last transaction to commit before committing
the current transaction. (Note that the leader itself does not wait the
parallelized transaction to be committed unless partial serialization mode is
active, where the parallel apply worker can no longer be reused.) See the
"commit order" and "worker interaction" sections below for details.

Tracking dependencies is necessary even when commit order is preserved.
Consider two transactions: TX-1 (INSERT row 1) and TX-2 (DELETE row 1). If
both are allowed to apply in parallel, TX-2's DELETE could be applied before
TX-1's INSERT, resulting in a delete_missing conflict.

Each parallel apply worker records the local end LSN of the transaction it
applies in shared memory. Subsequently, the leader gathers these local end LSNs
and logs them in the local 'lsn_mapping' for verifying whether they have been
flushed to disk (following the logic in get_flush_position()).

If no parallel apply worker is available, the leader will apply the transaction
independently.

For further details, please refer to the following:

--
dedendency tracking
--

The leader maintains a local hash table, using the remote change's replica
identity column values and relid as keys, with remote transaction IDs as values.
Before sending changes to the parallel apply worker, the leader computes a hash
using RI key values and the relid of the current change to search the hash
table. If an existing entry is found, the leader first updates the hash entry
with the receiving remote xid then tells the parallel worker to wait for it.

If the remote relation lacks a replica identity (RI), it indicates that only
INSERT can be replicated for this table. In such cases, the leader skips
dependency checks, allowing the parallel apply worker to proceed with applying
changes without delay. This is because the only potential conflict could happen
is related to the local unique key or foreign key, which that is yet to be
implemented.

In cases of TRUNCATE or remote schema changes affecting the entire table, the
leader retrieves all remote xids touching the same table (via sequential scans
of the hash table) and tells the parallel worker to wait for those transactions
to commit.

Hash entries are cleaned up once the transaction corresponding to the remote xid
in the entry has been committed. Clean-up typically occurs when collecting the
flush position of each transaction, but is forced if the hash table exceeds a
set threshold.

--
dedendency waiting
--

If a transaction is relied upon by others, the leader adds its xid to a shared
hash table. The shared hash table entry is cleared by the parallel apply worker
upon completing the transaction. Workers needing to wait for a transaction check
the shared hash table entry; if present, they lock the transaction ID (using
pa_lock_transaction). If absent, it indicates the transaction has been
committed, negating the need to wait.

--
commit order
--

We preserve publisher commit order for all transactions for two reasons:

1) User-visible consistency

Out-of-order commits can expose states on the subscriber that were never visible
on the publisher.

For example, suppose a user updates table A and then updates table B on the
publisher. If the subscriber commits those transactions out of order, a query
that sees the latest row in B might still see stale data in A. Although eventual
consistency would still be reached, that behavior may be unacceptable for some
users. In the future, we could provide a GUC to allow out-of-order commits for
users who prefer higher parallelism.

2) Replication progress tracking

We currently track replication progress using transaction commit LSNs. With
out-of-order commits, this becomes ambiguous after failures.

For example, if TX-2 is applied before TX-1 and replication stops due to an
error, we cannot reliably determine whether TX-1 was applied before restart.
As a result, transactions that were already committed on the subscriber may
be replayed.

--
worker interaction
--

After sending the COMMIT message for a transaction, the leader apply worker
does not wait for the parallel apply worker to finish applying that
transaction. Instead, it sends a PA_MSG_XACT_DEPENDENCY message to
the parallel apply worker, instructing it to wait for the last transaction to
commit. This allows the leader to remain busy receiving and dispatching
changes to more parallel apply workers, enabling greater parallelism in
transaction application.

Author: Zhijie Hou <[email protected]>
Author: Hayato Kuroda <[email protected]>
---
 .../replication/logical/applyparallelworker.c | 348 +++++++++++++++--
 src/backend/replication/logical/proto.c       |  38 ++
 src/backend/replication/logical/relation.c    |  31 ++
 src/backend/replication/logical/worker.c      | 358 ++++++++++++++++--
 src/include/replication/logicalproto.h        |   2 +
 src/include/replication/logicalrelation.h     |   2 +
 src/include/replication/worker_internal.h     |  10 +-
 src/test/subscription/meson.build             |   2 +-
 src/test/subscription/t/001_rep_changes.pl    |   2 +
 src/test/subscription/t/010_truncate.pl       |   2 +-
 src/test/subscription/t/015_stream.pl         |   8 +-
 src/test/subscription/t/026_stats.pl          |   1 +
 src/test/subscription/t/027_nosuperuser.pl    |   1 +
 src/test/subscription/t/050_parallel_apply.pl | 201 ++++++++++
 src/tools/pgindent/typedefs.list              |   2 +
 15 files changed, 929 insertions(+), 79 deletions(-)
 create mode 100644 src/test/subscription/t/050_parallel_apply.pl

diff --git a/src/backend/replication/logical/applyparallelworker.c b/src/backend/replication/logical/applyparallelworker.c
index 8c81b61b052..103db18a25b 100644
--- a/src/backend/replication/logical/applyparallelworker.c
+++ b/src/backend/replication/logical/applyparallelworker.c
@@ -14,6 +14,9 @@
  * ParallelApplyWorkerInfo which is required so the leader worker and parallel
  * apply workers can communicate with each other.
  *
+ * Streaming transactions
+ * ======================
+ *
  * The parallel apply workers are assigned (if available) as soon as xact's
  * first stream is received for subscriptions that have set their 'streaming'
  * option as parallel. The leader apply worker will send changes to this new
@@ -36,12 +39,9 @@
  * its information is added to the ParallelApplyWorkerPool. Once the worker
  * finishes applying the transaction, it is marked as available for re-use.
  * Now, before starting a new worker to apply the streaming transaction, we
- * check the list for any available worker. Note that we retain a maximum of
- * half the max_parallel_apply_workers_per_subscription workers in the pool and
- * after that, we simply exit the worker after applying the transaction.
- *
- * XXX This worker pool threshold is arbitrary and we can provide a GUC
- * variable for this in the future if required.
+ * check the list for any available worker. We do not stop workers in the pool
+ * unless partial of the changes of the transaction are serialized due to a send
+ * timeout.
  *
  * The leader apply worker will create a separate dynamic shared memory segment
  * when each parallel apply worker starts. The reason for this design is that
@@ -152,6 +152,75 @@
  * session-level locks because both locks could be acquired outside the
  * transaction, and the stream lock in the leader needs to persist across
  * transaction boundaries i.e. until the end of the streaming transaction.
+ *
+ * Non-streaming transactions
+ * ======================
+ * The handling is similar to streaming transactions, but including few
+ * differences:
+ *
+ * Transaction dependency
+ * ----------------------
+ * Before dispatching changes to a parallel worker, the leader verifies if the
+ * current modification affects the same row (identitied by replica identity
+ * key) as another ongoing transaction (see handle_dependency_on_change for
+ * details). If so, the leader sends a list of dependent transaction IDs to the
+ * parallel worker, indicating that the parallel apply worker must wait for
+ * these transactions to commit before proceeding.
+ *
+ * Tracking dependencies is necessary even when commit order is preserved.
+ * Consider two transactions: TX-1 (INSERT row 1) and TX-2 (DELETE row 1). If
+ * both are allowed to apply in parallel, TX-2's DELETE could be applied before
+ * TX-1's INSERT, resulting in a delete_missing conflict.
+ *
+ * Commit order
+ * ------------
+ * We preserve publisher commit order for all transactions for two reasons:
+ *
+ * 1) User-visible consistency
+ *
+ * Out-of-order commits can expose states on the subscriber that were never
+ * visible on the publisher.
+ *
+ * For example, suppose a user updates table A and then updates table B on the
+ * publisher. If the subscriber commits those transactions out of order, a
+ * query that sees the latest row in B might still see stale data in A.
+ * Although eventual consistency would still be reached, that behavior may be
+ * unacceptable for some users. In the future, we could provide a subscription
+ * option to allow out-of-order commits for users who prefer higher parallelism.
+ *
+ * 2) Replication progress tracking
+ *
+ * We currently track replication progress using the last transaction's commit
+ * LSNs. With out-of-order commits, this becomes ambiguous after failures.
+ *
+ * For example, if TX-2 is applied before TX-1 and replication stops due to an
+ * error, we cannot reliably determine whether TX-1 was applied before restart.
+ * As a result, transactions that were already committed on the subscriber may
+ * be replayed.
+ *
+ * Worker interaction
+ * ------------
+ * After sending the COMMIT message for a transaction, the leader apply worker
+ * does not wait for the parallel apply worker to finish applying that
+ * transaction. Instead, it sends a PA_MSG_XACT_DEPENDENCY message to
+ * the parallel apply worker, instructing it to wait for the last transaction to
+ * commit. This allows the leader to remain busy receiving and dispatching
+ * changes to more parallel apply workers, enabling greater parallelism in
+ * transaction application.
+ *
+ * Locking considerations
+ * ----------------------
+ * When handling a PA_MSG_RELMAP message, the worker attempts
+ * to acquire the transaction lock of the depended transaction and releases it
+ * immediately after acquisition (see pa_wait_for_depended_transaction). This
+ * allows deadlock detection when one worker (either leader or parallel apply
+ * worker) is waiting for a dependency on a transaction being applied by another
+ * worker, while that other worker is also blocked by a lock held by the first
+ * worker.
+ *
+ * The lock graph for the above example will look as follows: Worker_1 (waiting
+ * for depended transaction to finish) -> Worker_2 (waiting to acquire a
+ * relation lock) -> Worker_1
  *-------------------------------------------------------------------------
  */
 
@@ -307,6 +376,7 @@ static ParallelTransState pa_get_xact_state(ParallelApplyWorkerShared *wshared);
 static PartialFileSetState pa_get_fileset_state(void);
 static void pa_attach_parallelized_txn_hash(dsa_handle *pa_dsa_handle,
 											dshash_table_handle *pa_dshash_handle);
+static void write_internal_relation(StringInfo s, LogicalRepRelation *rel);
 
 /*
  * Returns true if it is OK to start a parallel apply worker, false otherwise.
@@ -424,6 +494,7 @@ pa_setup_dsm(ParallelApplyWorkerInfo *winfo)
 	shared = shm_toc_allocate(toc, sizeof(ParallelApplyWorkerShared));
 	SpinLockInit(&shared->mutex);
 
+	shared->xid = InvalidTransactionId;
 	shared->xact_state = PARALLEL_TRANS_UNKNOWN;
 	pg_atomic_init_u32(&(shared->pending_stream_count), 0);
 	shared->last_commit_end = InvalidXLogRecPtr;
@@ -467,6 +538,8 @@ pa_launch_parallel_worker(void)
 	MemoryContext oldcontext;
 	bool		launched;
 	ParallelApplyWorkerInfo *winfo;
+	dsa_handle	pa_dsa_handle;
+	dshash_table_handle pa_dshash_handle;
 	ListCell   *lc;
 
 	/* Try to get an available parallel apply worker from the worker pool. */
@@ -476,8 +549,36 @@ pa_launch_parallel_worker(void)
 
 		if (!winfo->in_use)
 			return winfo;
+
+		/*
+		 * The leader does not explicitly free workers that handle non-streaming
+		 * transactions, so we check whether the transaction has committed, and
+		 * if so, reuse the worker.
+		 */
+		if (!winfo->stream_txn &&
+			pa_get_xact_state(winfo->shared) == PARALLEL_TRANS_FINISHED)
+		{
+			/*
+			 * Save the local commit LSN of the last transaction applied by
+			 * this worker before reusing it for another transaction. This WAL
+			 * position is crucial for determining the flush position in
+			 * responses to the publisher (see get_flush_position()).
+			 */
+			(void) pa_get_last_commit_end(winfo->shared->xid, false, NULL);
+			return winfo;
+		}
 	}
 
+	pa_attach_parallelized_txn_hash(&pa_dsa_handle, &pa_dshash_handle);
+
+	/*
+	 * Return if the number of parallel apply workers has reached the maximum
+	 * limit.
+	 */
+	if (list_length(ParallelApplyWorkerPool) ==
+		max_parallel_apply_workers_per_subscription)
+		return NULL;
+
 	/*
 	 * Start a new parallel apply worker.
 	 *
@@ -505,18 +606,50 @@ pa_launch_parallel_worker(void)
 										dsm_segment_handle(winfo->dsm_seg),
 										false);
 
-	if (launched)
-	{
-		ParallelApplyWorkerPool = lappend(ParallelApplyWorkerPool, winfo);
-	}
-	else
+	if (!launched)
 	{
+		MemoryContextSwitchTo(oldcontext);
 		pa_free_worker_info(winfo);
-		winfo = NULL;
+		return NULL;
 	}
 
+	ParallelApplyWorkerPool = lappend(ParallelApplyWorkerPool, winfo);
+
 	MemoryContextSwitchTo(oldcontext);
 
+	/*
+	 * Send all existing remote relation information to the parallel apply
+	 * worker. This allows the parallel worker to initialize the
+	 * LogicalRepRelMapEntry locally before applying remote changes. This is
+	 * needed since the walsender does not send remote relation information
+	 * with every transaction.
+	 */
+	if (logicalrep_get_num_rels())
+	{
+		StringInfoData out;
+		shm_mq_result result;
+
+		initStringInfo(&out);
+
+		write_internal_relation(&out, NULL);
+
+		/*
+		 * Send relation information synchronously rather than using
+		 * non-blocking send (pa_send_data). This avoids the need to serialize
+		 * the data, which would add complexity for the parallel apply worker to
+		 * deserialize outside of transaction application.
+		 *
+		 * This is safe from deadlocks because a newly started worker does not
+		 * hold any strong locks when processing relation information.
+		 */
+		result = shm_mq_send(winfo->mq_handle, out.len, out.data, false, true);
+
+		if (result != SHM_MQ_SUCCESS)
+			ereport(ERROR,
+					errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+					errmsg("could not send remote relation information to the logical replication parallel apply worker"));
+	}
+
 	return winfo;
 }
 
@@ -621,25 +754,27 @@ pa_free_worker(ParallelApplyWorkerInfo *winfo)
 {
 	Assert(!am_parallel_apply_worker());
 	Assert(winfo->in_use);
-	Assert(pa_get_xact_state(winfo->shared) == PARALLEL_TRANS_FINISHED);
+	Assert(!winfo->stream_txn ||
+		   pa_get_xact_state(winfo->shared) == PARALLEL_TRANS_FINISHED);
 
 	if (!hash_search(ParallelApplyTxnHash, &winfo->shared->xid, HASH_REMOVE, NULL))
 		elog(ERROR, "hash table corrupted");
 
 	/*
-	 * Stop the worker if there are enough workers in the pool.
+	 * XXX we stop the worker if the leader apply worker serialize part of the
+	 * transaction data due to a send timeout. This is because the message could
+	 * be partially written to the queue and there is no way to clean the queue
+	 * other than resending the message until it succeeds. Instead of trying to
+	 * send the data which anyway would have been serialized and then letting
+	 * the parallel apply worker deal with the spurious message, we stop the
+	 * worker.
 	 *
-	 * XXX Additionally, we also stop the worker if the leader apply worker
-	 * serialize part of the transaction data due to a send timeout. This is
-	 * because the message could be partially written to the queue and there
-	 * is no way to clean the queue other than resending the message until it
-	 * succeeds. Instead of trying to send the data which anyway would have
-	 * been serialized and then letting the parallel apply worker deal with
-	 * the spurious message, we stop the worker.
+	 * For other cases, we do not stop workers once started. All transactions
+	 * (whether streamed or not) are assigned to parallel apply workers, so
+	 * restarting workers frequently would only increase CPU overhead and slow
+	 * down the leader's ability to dispatch changes to workers.
 	 */
-	if (winfo->serialize_changes ||
-		list_length(ParallelApplyWorkerPool) >
-		(max_parallel_apply_workers_per_subscription / 2))
+	if (winfo->serialize_changes)
 	{
 		logicalrep_pa_worker_stop(winfo);
 		pa_free_worker_info(winfo);
@@ -772,15 +907,17 @@ pa_process_spooled_messages_if_required(void)
 /*
  * Get the local end LSN for a transaction applied by a parallel apply worker.
  *
+ * Set delete_entry to true if you intend to remove the transaction from the
+ * ParallelApplyTxnHash after collecting its LSN. Otherwise, the local end LSN
+ * is copied from shared memory into the local entry of ParallelApplyTxnHash for
+ * later collection by the leader.
+ *
  * If the parallel apply worker did not write any changes during the transaction
  * application due to situations like update/delete_missing or a before trigger,
  * the *skipped_write will be set to true.
- *
- * Once the LSN is retrieved, the transaction entry is removed from
- * ParallelApplyTxnHash.
  */
 XLogRecPtr
-pa_get_last_commit_end(TransactionId xid, bool *skipped_write)
+pa_get_last_commit_end(TransactionId xid, bool delete_entry, bool *skipped_write)
 {
 	bool		found;
 	ParallelApplyWorkerEntry *entry;
@@ -806,7 +943,8 @@ pa_get_last_commit_end(TransactionId xid, bool *skipped_write)
 	winfo = entry->winfo;
 	if (winfo == NULL)
 	{
-		if (!hash_search(ParallelApplyTxnHash, &xid, HASH_REMOVE, NULL))
+		if (delete_entry &&
+			!hash_search(ParallelApplyTxnHash, &xid, HASH_REMOVE, NULL))
 			elog(ERROR, "hash table corrupted");
 
 		if (skipped_write)
@@ -829,7 +967,8 @@ pa_get_last_commit_end(TransactionId xid, bool *skipped_write)
 	elog(DEBUG1, "store local commit %X/%X end to txn entry: %u",
 		 LSN_FORMAT_ARGS(entry->local_end), xid);
 
-	if (!hash_search(ParallelApplyTxnHash, &xid, HASH_REMOVE, NULL))
+	if (delete_entry &&
+		!hash_search(ParallelApplyTxnHash, &xid, HASH_REMOVE, NULL))
 		elog(ERROR, "hash table corrupted");
 
 	return entry->local_end;
@@ -1082,6 +1221,9 @@ pa_shutdown(int code, Datum arg)
 				   INVALID_PROC_NUMBER);
 
 	dsm_detach((dsm_segment *) DatumGetPointer(arg));
+
+	if (parallel_apply_dsa_area)
+		dsa_detach(parallel_apply_dsa_area);
 }
 
 /*
@@ -1393,7 +1535,6 @@ pa_send_data(ParallelApplyWorkerInfo *winfo, Size nbytes, const void *data)
 	shm_mq_result result;
 	TimestampTz startTime = 0;
 
-	Assert(!IsTransactionState());
 	Assert(!winfo->serialize_changes);
 
 	/*
@@ -1445,6 +1586,58 @@ pa_send_data(ParallelApplyWorkerInfo *winfo, Size nbytes, const void *data)
 	}
 }
 
+/*
+ * Distribute remote relation information to all active parallel apply workers
+ * that require it.
+ */
+void
+pa_distribute_schema_changes_to_workers(LogicalRepRelation *rel)
+{
+	StringInfoData out;
+
+	/* Only leader apply workers can distribute schema changes */
+	if (!am_leader_apply_worker())
+		return;
+
+	/* Quick exit if there are no parallel apply workers */
+	if (!ParallelApplyWorkerPool)
+		return;
+
+	initStringInfo(&out);
+
+	write_internal_relation(&out, rel);
+
+	foreach_ptr(ParallelApplyWorkerInfo, winfo, ParallelApplyWorkerPool)
+	{
+		/*
+		 * Skip the worker responsible for the current transaction, as the
+		 * relation information has already been sent to it.
+		 */
+		if (winfo == stream_apply_worker)
+			continue;
+
+		/*
+		 * Skip the worker that is in serialize mode, as they will soon stop
+		 * once they finish applying the transaction.
+		 */
+		if (winfo->serialize_changes)
+			continue;
+
+		elog(DEBUG1, "distributing schema changes to pa workers");
+
+		if (pa_send_data(winfo, out.len, out.data))
+			continue;
+
+		/*
+		 * TODO: Support switching to PARTIAL_SERIALIZE mode when the send
+		 * buffer becomes full.
+		 */
+		ereport(ERROR,
+				errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+				errmsg("could not send data to the logical replication parallel apply worker for subscription"));
+	}
+}
+
 /*
  * Switch to PARTIAL_SERIALIZE mode for the current transaction -- this means
  * that the current data and any subsequent data for this transaction will be
@@ -1861,25 +2054,41 @@ pa_decr_and_wait_stream_block(void)
 void
 pa_xact_finish(ParallelApplyWorkerInfo *winfo, XLogRecPtr remote_lsn)
 {
+	XLogRecPtr	local_lsn = InvalidXLogRecPtr;
+	TransactionId pa_remote_xid = winfo->shared->xid;
+
 	Assert(am_leader_apply_worker());
 
 	/*
-	 * Unlock the shared object lock so that parallel apply worker can
-	 * continue to receive and apply changes.
+	 * Unlock the shared object lock taken for streaming transactions so that
+	 * parallel apply worker can continue to receive and apply changes.
 	 */
-	pa_unlock_stream(winfo->shared->xid, AccessExclusiveLock);
+	if (winfo->stream_txn)
+		pa_unlock_stream(winfo->shared->xid, AccessExclusiveLock);
 
 	/*
-	 * Wait for that worker to finish. This is necessary to maintain commit
-	 * order which avoids failures due to transaction dependencies and
-	 * deadlocks.
+	 * Wait for that worker for streaming transaction to finish. This is
+	 * necessary to maintain commit order which avoids failures due to
+	 * transaction dependencies and deadlocks.
+	 *
+	 * For transactions in partial seralize mode, wait for stop as well as the
+	 * worker is anyway cannot be reused anymore (see pa_free_worker() for
+	 * details).
 	 */
-	pa_wait_for_xact_finish(winfo);
+	if (winfo->serialize_changes || winfo->stream_txn)
+	{
+		pa_wait_for_xact_finish(winfo);
+
+		local_lsn = winfo->shared->last_commit_end;
+		pa_remote_xid = InvalidTransactionId;
+
+		pa_free_worker(winfo);
+	}
 
 	if (XLogRecPtrIsValid(remote_lsn))
-		store_flush_position(remote_lsn, winfo->shared->last_commit_end);
+		store_flush_position(remote_lsn, local_lsn, pa_remote_xid);
 
-	pa_free_worker(winfo);
+	pa_set_stream_apply_worker(NULL);
 }
 
 /*
@@ -2013,3 +2222,62 @@ pa_wait_for_depended_transaction(TransactionId xid)
 
 	elog(DEBUG1, "finish waiting for depended xid %u", xid);
 }
+
+/*
+ * Mark the transaction state as finished and remove the shared hash entry.
+ */
+void
+pa_commit_transaction(void)
+{
+	TransactionId xid = MyParallelShared->xid;
+
+	SpinLockAcquire(&MyParallelShared->mutex);
+	MyParallelShared->xact_state = PARALLEL_TRANS_FINISHED;
+	SpinLockRelease(&MyParallelShared->mutex);
+
+	dshash_delete_key(parallelized_txns, &xid);
+	elog(DEBUG1, "xid %u committed", xid);
+}
+
+/*
+ * Write internal relation description to the output stream.
+ */
+static void
+write_internal_relation(StringInfo s, LogicalRepRelation *rel)
+{
+	pq_sendbyte(s, LOGICAL_REP_MSG_INTERNAL_MESSAGE);
+	pq_sendbyte(s, PA_MSG_RELMAP);
+
+	if (rel)
+	{
+		pq_sendint(s, 1, 4);
+		logicalrep_write_internal_rel(s, rel);
+	}
+	else
+	{
+		pq_sendint(s, logicalrep_get_num_rels(), 4);
+		logicalrep_write_all_rels(s);
+	}
+}
+
+/*
+ * Register a transaction to the shared hash table.
+ *
+ * This function is called by the leader during the commit phase of non-streamed
+ * transactions. The parallel apply worker that applies the transaction will
+ * remove it from the hash table upon completion.
+ */
+void
+pa_add_parallelized_transaction(TransactionId xid)
+{
+	bool		found;
+	ParallelizedTxnEntry *txn_entry;
+
+	Assert(parallelized_txns);
+	Assert(TransactionIdIsValid(xid));
+	Assert(am_leader_apply_worker());
+
+	txn_entry = dshash_find_or_insert(parallelized_txns, &xid, &found);
+
+	dshash_release_lock(parallelized_txns, txn_entry);
+}
diff --git a/src/backend/replication/logical/proto.c b/src/backend/replication/logical/proto.c
index a0cac05cdcd..eb1a846ce0c 100644
--- a/src/backend/replication/logical/proto.c
+++ b/src/backend/replication/logical/proto.c
@@ -691,6 +691,44 @@ logicalrep_write_rel(StringInfo out, TransactionId xid, Relation rel,
 	logicalrep_write_attrs(out, rel, columns, include_gencols_type);
 }
 
+/*
+ * Write internal relation description to the output stream.
+ */
+void
+logicalrep_write_internal_rel(StringInfo out, LogicalRepRelation *rel)
+{
+	pq_sendint32(out, rel->remoteid);
+
+	/* Write relation name */
+	pq_sendstring(out, rel->nspname);
+	pq_sendstring(out, rel->relname);
+
+	/* Write the replica identity. */
+	pq_sendbyte(out, rel->replident);
+
+	/* Write attribute description */
+	pq_sendint16(out, rel->natts);
+
+	for (int i = 0; i < rel->natts; i++)
+	{
+		uint8		flags = 0;
+
+		if (bms_is_member(i, rel->attkeys))
+			flags |= LOGICALREP_IS_REPLICA_IDENTITY;
+
+		pq_sendbyte(out, flags);
+
+		/* attribute name */
+		pq_sendstring(out, rel->attnames[i]);
+
+		/* attribute type id */
+		pq_sendint32(out, rel->atttyps[i]);
+
+		/* ignore attribute mode for now */
+		pq_sendint32(out, 0);
+	}
+}
+
 /*
  * Read the relation info from stream and return as LogicalRepRelation.
  */
diff --git a/src/backend/replication/logical/relation.c b/src/backend/replication/logical/relation.c
index ab6313eb1bc..e1ce183dfd3 100644
--- a/src/backend/replication/logical/relation.c
+++ b/src/backend/replication/logical/relation.c
@@ -961,6 +961,37 @@ FindLogicalRepLocalIndex(Relation localrel, LogicalRepRelation *remoterel,
 	return InvalidOid;
 }
 
+/*
+ * Get the number of entries in the LogicalRepRelMap.
+ */
+int
+logicalrep_get_num_rels(void)
+{
+	if (LogicalRepRelMap == NULL)
+		return 0;
+
+	return hash_get_num_entries(LogicalRepRelMap);
+}
+
+/*
+ * Write all the remote relation information from the LogicalRepRelMapEntry to
+ * the output stream.
+ */
+void
+logicalrep_write_all_rels(StringInfo out)
+{
+	LogicalRepRelMapEntry *entry;
+	HASH_SEQ_STATUS status;
+
+	if (LogicalRepRelMap == NULL)
+		return;
+
+	hash_seq_init(&status, LogicalRepRelMap);
+
+	while ((entry = (LogicalRepRelMapEntry *) hash_seq_search(&status)) != NULL)
+		logicalrep_write_internal_rel(out, &entry->remoterel);
+}
+
 /*
  * Get the LogicalRepRelMapEntry corresponding to the given relid without
  * opening the local relation.
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index ee69f07f715..faf7a50bf25 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -290,6 +290,7 @@
 #include "tcop/tcopprot.h"
 #include "utils/acl.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/lsyscache.h"
 #include "utils/memutils.h"
@@ -514,6 +515,8 @@ static List *on_commit_wakeup_workers_subids = NIL;
 
 bool		in_remote_transaction = false;
 static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+static TransactionId remote_xid = InvalidTransactionId;
+static TransactionId last_parallelized_remote_xid = InvalidTransactionId;
 
 /* fields valid only when processing streamed transaction */
 static bool in_streamed_transaction = false;
@@ -722,6 +725,7 @@ static void on_exit_clear_xact_state(int code, Datum arg);
 
 static void send_internal_dependencies(ParallelApplyWorkerInfo *winfo,
 									   List *depends_on_xids);
+static void build_dependency_with_last_committed_txn(ParallelApplyWorkerInfo *winfo);
 
 /*
  * Compute the hash value for entries in the replica_identity_table.
@@ -837,7 +841,7 @@ cleanup_committed_replica_identity_entries(void)
 			XLogRecPtrIsValid(pos->local_end))
 			continue;
 
-		pos->local_end = pa_get_last_commit_end(pos->pa_remote_xid,
+		pos->local_end = pa_get_last_commit_end(pos->pa_remote_xid, false,
 												&skipped_write);
 
 		elog(DEBUG1,
@@ -1441,14 +1445,14 @@ handle_streamed_transaction(LogicalRepMsgType action, StringInfo s)
 	TransApplyAction apply_action;
 	StringInfoData original_msg;
 
+	if (!in_streamed_transaction)
+		return false;
+
 	apply_action = get_transaction_apply_action(stream_xid, &winfo);
 
 	/* not in streaming mode */
 	if (apply_action == TRANS_LEADER_APPLY)
-	{
-		handle_dependency_on_change(action, s, InvalidTransactionId, winfo);
 		return false;
-	}
 
 	Assert(TransactionIdIsValid(stream_xid));
 
@@ -1523,6 +1527,73 @@ handle_streamed_transaction(LogicalRepMsgType action, StringInfo s)
 	}
 }
 
+/*
+ * Handle non-streaming transactions when parallel apply is in use.
+ *
+ * This function runs only in the leader apply worker while processing a remote
+ * transaction. It checks whether the current change has dependencies on
+ * preceding parallelized transactions and decides whether to send the change to
+ * a parallel apply worker.
+ *
+ * Returns true if the change has been dispatched to a parallel worker,
+ * indicating the leader does not need to apply it directly. Returns false
+ * otherwise.
+ *
+ * Exception: If the message being processed is LOGICAL_REP_MSG_RELATION or
+ * LOGICAL_REP_MSG_TYPE, return false even if the message needs to be sent to a
+ * parallel apply worker.
+ */
+static bool
+handle_parallelized_transaction(LogicalRepMsgType action, StringInfo s)
+{
+	ParallelApplyWorkerInfo *winfo;
+	TransApplyAction apply_action;
+
+	/*
+	 * Dependency checking for non-streaming transactions is only required in
+	 * the leader apply worker during a remote transaction.
+	 */
+	if (!in_remote_transaction || !am_leader_apply_worker())
+		return false;
+
+	apply_action = get_transaction_apply_action(remote_xid, &winfo);
+
+	/* not assigned to parallel apply worker, apply in leader */
+	if (apply_action == TRANS_LEADER_APPLY)
+	{
+		handle_dependency_on_change(action, s, InvalidTransactionId, winfo);
+		return false;
+	}
+
+	Assert(TransactionIdIsValid(remote_xid));
+
+	handle_dependency_on_change(action, s, remote_xid, winfo);
+
+	switch (apply_action)
+	{
+		case TRANS_LEADER_SEND_TO_PARALLEL:
+			Assert(winfo);
+
+			/* Always update relation and type cache in leader apply worker */
+			if (pa_send_data(winfo, s->len, s->data))
+				return (action != LOGICAL_REP_MSG_RELATION &&
+						action != LOGICAL_REP_MSG_TYPE);
+
+			/*
+			 * TODO: Support switching to PARTIAL_SERIALIZE mode when the send
+			 * buffer becomes full.
+			 */
+			ereport(ERROR,
+					errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+					errmsg("could not send data to the logical replication parallel apply worker"));
+			return false;	/* silence compiler warning */
+
+		default:
+			elog(ERROR, "unexpected apply action: %d", (int) apply_action);
+			return false;		/* silence compiler warning */
+	}
+}
+
 /*
  * Executor state preparation for evaluation of constraint expressions,
  * indexes and triggers for the specified relation.
@@ -1884,17 +1955,61 @@ static void
 apply_handle_begin(StringInfo s)
 {
 	LogicalRepBeginData begin_data;
+	ParallelApplyWorkerInfo *winfo;
+	TransApplyAction apply_action;
 
 	/* There must not be an active streaming transaction. */
 	Assert(!TransactionIdIsValid(stream_xid));
 
 	logicalrep_read_begin(s, &begin_data);
-	set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
+
+	remote_xid = begin_data.xid;
+
+	set_apply_error_context_xact(remote_xid, begin_data.final_lsn);
 
 	remote_final_lsn = begin_data.final_lsn;
 
 	maybe_start_skipping_changes(begin_data.final_lsn);
 
+	pa_allocate_worker(remote_xid, false);
+
+	apply_action = get_transaction_apply_action(remote_xid, &winfo);
+
+	elog(DEBUG1, "new remote_xid %u", remote_xid);
+	switch (apply_action)
+	{
+		case TRANS_LEADER_APPLY:
+			break;
+
+		case TRANS_LEADER_SEND_TO_PARALLEL:
+			Assert(winfo);
+
+			if (pa_send_data(winfo, s->len, s->data))
+			{
+				pa_set_stream_apply_worker(winfo);
+				break;
+			}
+
+			/*
+			 * TODO: Support switching to PARTIAL_SERIALIZE mode when the send
+			 * buffer becomes full.
+			 */
+			ereport(ERROR,
+					errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+					errmsg("could not send data to the logical replication parallel apply worker"));
+			break;
+
+		case TRANS_PARALLEL_APPLY:
+			/* Hold the lock until the end of the transaction. */
+			pa_lock_transaction(MyParallelShared->xid, AccessExclusiveLock);
+			pa_set_xact_state(MyParallelShared, PARALLEL_TRANS_STARTED);
+			break;
+
+		default:
+			elog(ERROR, "unexpected apply action: %d", (int) apply_action);
+			break;
+	}
+
 	in_remote_transaction = true;
 
 	pgstat_report_activity(STATE_RUNNING, NULL);
@@ -1917,12 +2032,33 @@ send_internal_dependencies(ParallelApplyWorkerInfo *winfo, List *depends_on_xids
 	foreach_xid(xid, depends_on_xids)
 		pq_sendint32(&dependencies, xid);
 
-	if (pa_send_data(winfo, dependencies.len, dependencies.data))
+	/*
+	 * TODO: Support switching to PARTIAL_SERIALIZE mode when the send
+	 * buffer becomes full.
+	 */
+	if (!pa_send_data(winfo, dependencies.len, dependencies.data))
 		ereport(ERROR,
 				errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
 				errmsg("could not send data to the logical replication parallel apply worker"));
 }
 
+/*
+ * Make a dependency between this and the lastly committed transaction.
+ *
+ * Sends an INTERNAL_DEPENDENCY message to the parallel apply worker,
+ * instructing it to wait for the last committed transaction to finish before
+ * committing its own, thereby preserving commit order.
+ */
+static void
+build_dependency_with_last_committed_txn(ParallelApplyWorkerInfo *winfo)
+{
+	/* Skip if transactions have not been applied yet */
+	if (!TransactionIdIsValid(last_parallelized_remote_xid))
+		return;
+
+	send_internal_dependencies(winfo, list_make1_xid(last_parallelized_remote_xid));
+}
+
 /*
  * Handle COMMIT message.
  *
@@ -1932,6 +2068,8 @@ static void
 apply_handle_commit(StringInfo s)
 {
 	LogicalRepCommitData commit_data;
+	ParallelApplyWorkerInfo *winfo;
+	TransApplyAction apply_action;
 
 	logicalrep_read_commit(s, &commit_data);
 
@@ -1942,7 +2080,92 @@ apply_handle_commit(StringInfo s)
 								 LSN_FORMAT_ARGS(commit_data.commit_lsn),
 								 LSN_FORMAT_ARGS(remote_final_lsn))));
 
-	apply_handle_commit_internal(&commit_data);
+	apply_action = get_transaction_apply_action(remote_xid, &winfo);
+
+	switch (apply_action)
+	{
+		case TRANS_LEADER_APPLY:
+
+			/*
+			 * Apart from parallelized transactions, we do not have to
+			 * register this transaction to parallelized_txns. The commit
+			 * ordering is always preserved.
+			 */
+
+			/* Wait until the last transaction finishes */
+			if (TransactionIdIsValid(last_parallelized_remote_xid))
+			{
+				pa_wait_for_depended_transaction(last_parallelized_remote_xid);
+				last_parallelized_remote_xid = InvalidTransactionId;
+			}
+
+			apply_handle_commit_internal(&commit_data);
+
+			break;
+
+		case TRANS_LEADER_SEND_TO_PARALLEL:
+			Assert(winfo);
+
+			/*
+			 * Mark this transaction as parallelized. This ensures that
+			 * upcoming transactions wait until this transaction is committed.
+			 */
+			pa_add_parallelized_transaction(remote_xid);
+
+			/*
+			 * Build a dependency between this transaction and the lastly
+			 * committed transaction to preserve the commit order.
+			 */
+			build_dependency_with_last_committed_txn(winfo);
+
+			if (pa_send_data(winfo, s->len, s->data))
+			{
+				/* Cache the remote_xid */
+				last_parallelized_remote_xid = remote_xid;
+
+				/* Finish processing the transaction. */
+				pa_xact_finish(winfo, commit_data.end_lsn);
+				break;
+			}
+
+			/*
+			 * TODO: Support switching to PARTIAL_SERIALIZE mode when the send
+			 * buffer becomes full.
+			 */
+			ereport(ERROR,
+					errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+					errmsg("could not send data to the logical replication parallel apply worker"));
+			break;
+
+		case TRANS_PARALLEL_APPLY:
+
+			/*
+			 * If the parallel apply worker is applying spooled messages then
+			 * close the file before committing.
+			 */
+			if (stream_fd)
+				stream_close_file();
+
+			INJECTION_POINT("parallel-worker-before-commit", NULL);
+
+			apply_handle_commit_internal(&commit_data);
+
+			MyParallelShared->last_commit_end = XactLastCommitEnd;
+
+			pa_commit_transaction();
+
+			pa_unlock_transaction(remote_xid, AccessExclusiveLock);
+			break;
+
+		default:
+			elog(ERROR, "unexpected apply action: %d", (int) apply_action);
+			break;
+	}
+
+	remote_xid = InvalidTransactionId;
+	in_remote_transaction = false;
+
+	elog(DEBUG1, "reset remote_xid %u", remote_xid);
 
 	/*
 	 * Process any tables that are being synchronized in parallel, as well as
@@ -2065,7 +2288,8 @@ apply_handle_prepare(StringInfo s)
 	 * XactLastCommitEnd, and adding it for this purpose doesn't seems worth
 	 * it.
 	 */
-	store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr);
+	store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr,
+						 InvalidTransactionId);
 
 	in_remote_transaction = false;
 
@@ -2125,7 +2349,8 @@ apply_handle_commit_prepared(StringInfo s)
 	CommitTransactionCommand();
 	pgstat_report_stat(false);
 
-	store_flush_position(prepare_data.end_lsn, XactLastCommitEnd);
+	store_flush_position(prepare_data.end_lsn, XactLastCommitEnd,
+						 InvalidTransactionId);
 	in_remote_transaction = false;
 
 	/*
@@ -2194,7 +2419,8 @@ apply_handle_rollback_prepared(StringInfo s)
 	 * transaction because we always flush the WAL record for it. See
 	 * apply_handle_prepare.
 	 */
-	store_flush_position(rollback_data.rollback_end_lsn, InvalidXLogRecPtr);
+	store_flush_position(rollback_data.rollback_end_lsn, InvalidXLogRecPtr,
+						 InvalidTransactionId);
 	in_remote_transaction = false;
 
 	/*
@@ -2256,7 +2482,8 @@ apply_handle_stream_prepare(StringInfo s)
 			 * It is okay not to set the local_end LSN for the prepare because
 			 * we always flush the prepare record. See apply_handle_prepare.
 			 */
-			store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr);
+			store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr,
+								 InvalidTransactionId);
 
 			in_remote_transaction = false;
 
@@ -2448,8 +2675,17 @@ apply_handle_stream_start(StringInfo s)
 
 	set_apply_error_context_xact(stream_xid, InvalidXLogRecPtr);
 
-	/* Try to allocate a worker for the streaming transaction. */
-	if (first_segment)
+	/*
+	 * Try to allocate a worker for the streaming transaction.
+	 *
+	 * TODO: Support assigning streaming transactions to parallel apply workers
+	 * even when non-streaming transactions are running and better dependency
+	 * handling between streaming and non-streaming transactions.
+	 */
+	if (first_segment &&
+		am_leader_apply_worker() &&
+		(!TransactionIdIsValid(last_parallelized_remote_xid) ||
+		 pa_transaction_committed(last_parallelized_remote_xid)))
 		pa_allocate_worker(stream_xid, true);
 
 	apply_action = get_transaction_apply_action(stream_xid, &winfo);
@@ -2964,8 +3200,7 @@ apply_spooled_messages(FileSet *stream_fileset, TransactionId xid,
 	int			fileno;
 	pgoff_t		offset;
 
-	if (!am_parallel_apply_worker())
-		maybe_start_skipping_changes(lsn);
+	maybe_start_skipping_changes(lsn);
 
 	/* Make sure we have an open transaction */
 	begin_replication_step();
@@ -3235,7 +3470,8 @@ apply_handle_commit_internal(LogicalRepCommitData *commit_data)
 
 		pgstat_report_stat(false);
 
-		store_flush_position(commit_data->end_lsn, XactLastCommitEnd);
+		store_flush_position(commit_data->end_lsn, XactLastCommitEnd,
+							 InvalidTransactionId);
 	}
 	else
 	{
@@ -3260,7 +3496,8 @@ apply_handle_relation(StringInfo s)
 {
 	LogicalRepRelation *rel;
 
-	if (handle_streamed_transaction(LOGICAL_REP_MSG_RELATION, s))
+	if (handle_parallelized_transaction(LOGICAL_REP_MSG_RELATION, s) ||
+		handle_streamed_transaction(LOGICAL_REP_MSG_RELATION, s))
 		return;
 
 	rel = logicalrep_read_rel(s);
@@ -3268,6 +3505,8 @@ apply_handle_relation(StringInfo s)
 
 	/* Also reset all entries in the partition map that refer to remoterel. */
 	logicalrep_partmap_reset_relmap(rel);
+
+	pa_distribute_schema_changes_to_workers(rel);
 }
 
 /*
@@ -3283,7 +3522,8 @@ apply_handle_type(StringInfo s)
 {
 	LogicalRepTyp typ;
 
-	if (handle_streamed_transaction(LOGICAL_REP_MSG_TYPE, s))
+	if (handle_parallelized_transaction(LOGICAL_REP_MSG_TYPE, s) ||
+		handle_streamed_transaction(LOGICAL_REP_MSG_TYPE, s))
 		return;
 
 	logicalrep_read_typ(s, &typ);
@@ -3343,6 +3583,7 @@ apply_handle_insert(StringInfo s)
 	 * streamed transactions.
 	 */
 	if (is_skipping_changes() ||
+		handle_parallelized_transaction(LOGICAL_REP_MSG_INSERT, s) ||
 		handle_streamed_transaction(LOGICAL_REP_MSG_INSERT, s))
 		return;
 
@@ -3503,6 +3744,7 @@ apply_handle_update(StringInfo s)
 	 * streamed transactions.
 	 */
 	if (is_skipping_changes() ||
+		handle_parallelized_transaction(LOGICAL_REP_MSG_UPDATE, s) ||
 		handle_streamed_transaction(LOGICAL_REP_MSG_UPDATE, s))
 		return;
 
@@ -3727,6 +3969,7 @@ apply_handle_delete(StringInfo s)
 	 * streamed transactions.
 	 */
 	if (is_skipping_changes() ||
+		handle_parallelized_transaction(LOGICAL_REP_MSG_DELETE, s) ||
 		handle_streamed_transaction(LOGICAL_REP_MSG_DELETE, s))
 		return;
 
@@ -4363,6 +4606,7 @@ apply_handle_truncate(StringInfo s)
 	 * streamed transactions.
 	 */
 	if (is_skipping_changes() ||
+		handle_parallelized_transaction(LOGICAL_REP_MSG_TRUNCATE, s) ||
 		handle_streamed_transaction(LOGICAL_REP_MSG_TRUNCATE, s))
 		return;
 
@@ -4593,6 +4837,10 @@ apply_dispatch(StringInfo s)
  * check which entries on it are already locally flushed. Those we can report
  * as having been flushed.
  *
+ * For non-streaming transactions managed by a parallel apply worker, we will
+ * get the local commit end from the shared parallel apply worker info once the
+ * transaction has been committed by the worker.
+ *
  * The have_pending_txes is true if there are outstanding transactions that
  * need to be flushed.
  */
@@ -4602,6 +4850,7 @@ get_flush_position(XLogRecPtr *write, XLogRecPtr *flush,
 {
 	dlist_mutable_iter iter;
 	XLogRecPtr	local_flush = GetFlushRecPtr(NULL);
+	List	   *committed_pa_xid = NIL;
 
 	*write = InvalidXLogRecPtr;
 	*flush = InvalidXLogRecPtr;
@@ -4611,6 +4860,40 @@ get_flush_position(XLogRecPtr *write, XLogRecPtr *flush,
 		FlushPosition *pos =
 			dlist_container(FlushPosition, node, iter.cur);
 
+		/*
+		 * If the transaction was assigned to a parallel apply worker, attempt
+		 * to retrieve its commit end LSN from that worker.
+		 */
+		if (TransactionIdIsValid(pos->pa_remote_xid) &&
+			!XLogRecPtrIsValid(pos->local_end))
+		{
+			bool		skipped_write;
+
+			pos->local_end = pa_get_last_commit_end(pos->pa_remote_xid, true,
+													&skipped_write);
+
+			elog(DEBUG1,
+				 "got commit end from parallel apply worker, "
+				 "txn: %u, remote_end %X/%X, local_end %X/%X",
+				 pos->pa_remote_xid, LSN_FORMAT_ARGS(pos->remote_end),
+				 LSN_FORMAT_ARGS(pos->local_end));
+
+			/*
+			 * Break the loop if the worker has not finished applying the
+			 * transaction. There's no need to check subsequent transactions,
+			 * as they must commit after the current transaction being
+			 * examined and thus won't have their commit end available yet.
+			 */
+			if (!skipped_write && XLogRecPtrIsInvalid(pos->local_end))
+				break;
+
+			committed_pa_xid = lappend_xid(committed_pa_xid, pos->pa_remote_xid);
+		}
+
+		/*
+		 * Worker has finished applying or the transaction was applied in the
+		 * leader apply worker.
+		 */
 		*write = pos->remote_end;
 
 		if (pos->local_end <= local_flush)
@@ -4619,29 +4902,22 @@ get_flush_position(XLogRecPtr *write, XLogRecPtr *flush,
 			dlist_delete(iter.cur);
 			pfree(pos);
 		}
-		else
-		{
-			/*
-			 * Don't want to uselessly iterate over the rest of the list which
-			 * could potentially be long. Instead get the last element and
-			 * grab the write position from there.
-			 */
-			pos = dlist_tail_element(FlushPosition, node,
-									 &lsn_mapping);
-			*write = pos->remote_end;
-			*have_pending_txes = true;
-			return;
-		}
 	}
 
 	*have_pending_txes = !dlist_is_empty(&lsn_mapping);
+
+	delete_replica_identity_entries_for_txns(committed_pa_xid);
 }
 
 /*
  * Store current remote/local lsn pair in the tracking list.
+ *
+ * pa_remote_xid should be a valid transaction ID only when the transaction was
+ * assigned to a parallel apply worker; otherwise, pass InvalidTransactionId.
  */
 void
-store_flush_position(XLogRecPtr remote_lsn, XLogRecPtr local_lsn)
+store_flush_position(XLogRecPtr remote_lsn, XLogRecPtr local_lsn,
+					 TransactionId pa_remote_xid)
 {
 	FlushPosition *flushpos;
 
@@ -4659,7 +4935,7 @@ store_flush_position(XLogRecPtr remote_lsn, XLogRecPtr local_lsn)
 	flushpos = palloc_object(FlushPosition);
 	flushpos->local_end = local_lsn;
 	flushpos->remote_end = remote_lsn;
-	flushpos->pa_remote_xid = InvalidTransactionId;
+	flushpos->pa_remote_xid = pa_remote_xid;
 
 	dlist_push_tail(&lsn_mapping, &flushpos->node);
 	MemoryContextSwitchTo(ApplyMessageContext);
@@ -6784,6 +7060,22 @@ static void
 maybe_start_skipping_changes(XLogRecPtr finish_lsn)
 {
 	Assert(!is_skipping_changes());
+
+	/*
+	 * For streaming transactions applied in a parallel apply worker, the remote
+	 * end LSN is unknown, so skipping is not possible. For non-streaming
+	 * transactions, the leader determines whether to skip the transaction. If
+	 * skipping is needed, the leader simply does not send the transaction to a
+	 * parallel apply worker.
+	 */
+	if (am_parallel_apply_worker())
+		return;
+
+	/*
+	 * These assertions apply only to leader apply and table sync workers.
+	 * Parallel workers may apply spooled BEGIN or STREAM_START messages, which
+	 * can set these flags to true.
+	 */
 	Assert(!in_remote_transaction);
 	Assert(!in_streamed_transaction);
 
diff --git a/src/include/replication/logicalproto.h b/src/include/replication/logicalproto.h
index cb0a8f440c0..d9f375295de 100644
--- a/src/include/replication/logicalproto.h
+++ b/src/include/replication/logicalproto.h
@@ -259,6 +259,8 @@ extern void logicalrep_write_message(StringInfo out, TransactionId xid, XLogRecP
 extern void logicalrep_write_rel(StringInfo out, TransactionId xid,
 								 Relation rel, Bitmapset *columns,
 								 PublishGencolsType include_gencols_type);
+extern void logicalrep_write_internal_rel(StringInfo out,
+										  LogicalRepRelation *rel);
 extern LogicalRepRelation *logicalrep_read_rel(StringInfo in);
 extern void logicalrep_write_typ(StringInfo out, TransactionId xid,
 								 Oid typoid);
diff --git a/src/include/replication/logicalrelation.h b/src/include/replication/logicalrelation.h
index 09890cef8d4..cd852337165 100644
--- a/src/include/replication/logicalrelation.h
+++ b/src/include/replication/logicalrelation.h
@@ -58,6 +58,8 @@ extern void logicalrep_rel_close(LogicalRepRelMapEntry *rel,
 								 LOCKMODE lockmode);
 extern bool IsIndexUsableForReplicaIdentityFull(Relation idxrel, AttrMap *attrmap);
 extern Oid	GetRelationIdentityOrPK(Relation rel);
+extern int	logicalrep_get_num_rels(void);
+extern void logicalrep_write_all_rels(StringInfo out);
 extern LogicalRepRelMapEntry *logicalrep_get_relentry(LogicalRepRelId remoteid);
 
 #endif							/* LOGICALRELATION_H */
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index 3c88d9e1027..02a165ccc0d 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -345,7 +345,8 @@ extern void SetupApplyOrSyncWorker(int worker_slot);
 
 extern void DisableSubscriptionAndExit(void);
 
-extern void store_flush_position(XLogRecPtr remote_lsn, XLogRecPtr local_lsn);
+extern void store_flush_position(XLogRecPtr remote_lsn, XLogRecPtr local_lsn,
+								 TransactionId pa_remote_xid);
 
 /* Function for apply error callback */
 extern void apply_error_callback(void *arg);
@@ -354,13 +355,15 @@ extern void set_apply_error_context_origin(char *originname);
 /* Parallel apply worker setup and interactions */
 extern void pa_allocate_worker(TransactionId xid, bool stream_txn);
 extern ParallelApplyWorkerInfo *pa_find_worker(TransactionId xid);
-extern XLogRecPtr pa_get_last_commit_end(TransactionId xid, bool *skipped_write);
+extern XLogRecPtr pa_get_last_commit_end(TransactionId xid, bool delete_entry,
+										 bool *skipped_write);
 extern void pa_detach_all_error_mq(void);
 
 extern void apply_handle_internal_message(StringInfo s);
 
 extern bool pa_send_data(ParallelApplyWorkerInfo *winfo, Size nbytes,
 						 const void *data);
+extern void pa_distribute_schema_changes_to_workers(LogicalRepRelation *rel);
 extern void pa_switch_to_partial_serialize(ParallelApplyWorkerInfo *winfo,
 										   bool stream_locked);
 
@@ -386,8 +389,9 @@ extern void pa_decr_and_wait_stream_block(void);
 extern void pa_xact_finish(ParallelApplyWorkerInfo *winfo,
 						   XLogRecPtr remote_lsn);
 extern bool pa_transaction_committed(TransactionId xid);
-
+extern void pa_commit_transaction(void);
 extern void pa_wait_for_depended_transaction(TransactionId xid);
+extern void pa_add_parallelized_transaction(TransactionId xid);
 
 #define isParallelApplyWorker(worker) ((worker)->in_use && \
 									   (worker)->type == WORKERTYPE_PARALLEL_APPLY)
diff --git a/src/test/subscription/meson.build b/src/test/subscription/meson.build
index fdc04d91f41..48ae698e786 100644
--- a/src/test/subscription/meson.build
+++ b/src/test/subscription/meson.build
@@ -48,7 +48,7 @@ tests += {
       't/036_sequences.pl',
       't/037_except.pl',
       't/038_walsnd_shutdown_timeout.pl',
-      't/039_row_filter_toast_ri.pl',
+      't/050_parallel_apply.pl',
       't/100_bugs.pl',
     ],
   },
diff --git a/src/test/subscription/t/001_rep_changes.pl b/src/test/subscription/t/001_rep_changes.pl
index 7d41715ed81..c863b430bec 100644
--- a/src/test/subscription/t/001_rep_changes.pl
+++ b/src/test/subscription/t/001_rep_changes.pl
@@ -16,6 +16,8 @@ $node_publisher->start;
 # Create subscriber node
 my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
 $node_subscriber->init;
+$node_subscriber->append_conf('postgresql.conf',
+	"max_logical_replication_workers = 10");
 $node_subscriber->start;
 
 # Create some preexisting content on publisher
diff --git a/src/test/subscription/t/010_truncate.pl b/src/test/subscription/t/010_truncate.pl
index 945505d0239..e15a6bb2a03 100644
--- a/src/test/subscription/t/010_truncate.pl
+++ b/src/test/subscription/t/010_truncate.pl
@@ -17,7 +17,7 @@ $node_publisher->start;
 my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
 $node_subscriber->init;
 $node_subscriber->append_conf('postgresql.conf',
-	qq(max_logical_replication_workers = 6));
+	qq(max_logical_replication_workers = 7));
 $node_subscriber->start;
 
 my $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
diff --git a/src/test/subscription/t/015_stream.pl b/src/test/subscription/t/015_stream.pl
index ac96bc3f009..705bc83f5ea 100644
--- a/src/test/subscription/t/015_stream.pl
+++ b/src/test/subscription/t/015_stream.pl
@@ -232,6 +232,12 @@ $node_subscriber->wait_for_log(
 
 $node_publisher->safe_psql('postgres', "INSERT INTO test_tab_2 values(1)");
 
+# FIXME: Currently, non-streaming transactions are applied in parallel by
+# default. So, the first transaction is handled by a parallel apply worker. To
+# trigger the deadlock, initiate an more transaction to be applied by the
+# leader.
+$node_publisher->safe_psql('postgres', "INSERT INTO test_tab_2 values(1)");
+
 $h->query_safe('COMMIT');
 $h->quit;
 
@@ -247,7 +253,7 @@ $node_publisher->wait_for_catchup($appname);
 
 $result =
   $node_subscriber->safe_psql('postgres', "SELECT count(*) FROM test_tab_2");
-is($result, qq(5001), 'data replicated to subscriber after dropping index');
+is($result, qq(5002), 'data replicated to subscriber after dropping index');
 
 # Clean up test data from the environment.
 $node_publisher->safe_psql('postgres', "TRUNCATE TABLE test_tab_2");
diff --git a/src/test/subscription/t/026_stats.pl b/src/test/subscription/t/026_stats.pl
index 5d457060a02..911005bde20 100644
--- a/src/test/subscription/t/026_stats.pl
+++ b/src/test/subscription/t/026_stats.pl
@@ -16,6 +16,7 @@ $node_publisher->start;
 # Create subscriber node.
 my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
 $node_subscriber->init;
+$node_subscriber->append_conf('postgresql.conf', "max_logical_replication_workers = 10");
 $node_subscriber->start;
 
 
diff --git a/src/test/subscription/t/027_nosuperuser.pl b/src/test/subscription/t/027_nosuperuser.pl
index 322f5b4cc6a..fdfc44ac729 100644
--- a/src/test/subscription/t/027_nosuperuser.pl
+++ b/src/test/subscription/t/027_nosuperuser.pl
@@ -86,6 +86,7 @@ $node_publisher = PostgreSQL::Test::Cluster->new('publisher');
 $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
 $node_publisher->init(allows_streaming => 'logical');
 $node_subscriber->init;
+$node_subscriber->append_conf('postgresql.conf', "max_logical_replication_workers = 10");
 $node_publisher->start;
 $node_subscriber->start;
 $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
diff --git a/src/test/subscription/t/050_parallel_apply.pl b/src/test/subscription/t/050_parallel_apply.pl
new file mode 100644
index 00000000000..14121b23f23
--- /dev/null
+++ b/src/test/subscription/t/050_parallel_apply.pl
@@ -0,0 +1,201 @@
+
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+# This test verifies that a non-streamed transaction can launch a parallel apply
+# worker, and that dependency tracking and commit order preservation work
+# correctly during parallel apply.
+
+use strict;
+use warnings;
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+if ($ENV{enable_injection_points} ne 'yes')
+{
+	plan skip_all => 'Injection points not supported by this build';
+}
+
+# Initialize publisher node
+my $node_publisher = PostgreSQL::Test::Cluster->new('publisher');
+$node_publisher->init(allows_streaming => 'logical');
+$node_publisher->start;
+
+# Create tables and insert initial data
+$node_publisher->safe_psql(
+    'postgres', qq(
+    CREATE TABLE regress_tab (id int PRIMARY KEY, value text);
+
+    CREATE TABLE tab_toast (a text NOT NULL, b text NOT NULL);
+    ALTER TABLE tab_toast ALTER COLUMN a SET STORAGE EXTERNAL;
+    CREATE UNIQUE INDEX tab_toast_ri_index on tab_toast (a, b);
+    ALTER TABLE tab_toast REPLICA IDENTITY USING INDEX tab_toast_ri_index;
+    INSERT INTO tab_toast(a, b) VALUES(repeat('1234567890', 200), '1234567890');
+));
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(1, 10), 'test');");
+
+# Create a publication
+$node_publisher->safe_psql('postgres',
+    "CREATE PUBLICATION regress_pub FOR ALL TABLES;");
+
+# Initialize subscriber node
+my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
+$node_subscriber->init;
+$node_subscriber->append_conf('postgresql.conf', "log_min_messages = debug1");
+$node_subscriber->append_conf('postgresql.conf',
+	"max_logical_replication_workers = 10");
+$node_subscriber->start;
+
+# Check if the extension injection_points is available, as it may be
+# possible that this script is run with installcheck, where the module
+# would not be installed by default.
+if (!$node_subscriber->check_extension('injection_points'))
+{
+	plan skip_all => 'Extension injection_points not installed';
+}
+
+$node_subscriber->safe_psql('postgres', 'CREATE EXTENSION injection_points;');
+
+# Create a subscription
+my $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
+
+$node_subscriber->safe_psql(
+    'postgres', qq(
+    CREATE TABLE regress_tab (id int PRIMARY KEY, value text);
+
+    CREATE TABLE tab_toast (a text NOT NULL, b text NOT NULL);
+    ALTER TABLE tab_toast ALTER COLUMN a SET STORAGE EXTERNAL;
+    CREATE UNIQUE INDEX tab_toast_ri_index on tab_toast (a, b);
+    ALTER TABLE tab_toast REPLICA IDENTITY USING INDEX tab_toast_ri_index;
+));
+$node_subscriber->safe_psql('postgres',
+    "CREATE SUBSCRIPTION regress_sub CONNECTION '$publisher_connstr' PUBLICATION regress_pub;");
+
+# Wait for initial table sync to finish
+$node_subscriber->wait_for_subscription_sync($node_publisher, 'regress_sub');
+
+##################################################
+# Test that a non-streamed transaction can be applied in a parallel apply worker
+##################################################
+
+# Insert tuples on publisher
+#
+# XXX This may not enough to launch a parallel apply worker, because
+# table_states_not_ready is not discarded yet.
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(11, 20), 'test');");
+$node_publisher->wait_for_catchup('regress_sub');
+
+# Insert tuples again
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(21, 30), 'test');");
+$node_publisher->wait_for_catchup('regress_sub');
+
+# Verify the parallel apply worker is launched
+my $result = $node_subscriber->safe_psql('postgres',
+    "SELECT count(1) FROM pg_stat_activity WHERE backend_type = 'logical replication parallel worker'");
+is($result, '1', "parallel apply worker is launched by a non-streamed transaction");
+
+##################################################
+# Test that the basic replica identity dependency tracking and commit order
+# preservation work correctly during parallel apply.
+##################################################
+
+# Attach an injection_point. Parallel workers would wait before the commit
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+# Insert tuples on publisher
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(31, 40), 'test');");
+
+# Wait until the parallel worker enters the injection point.
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+my $offset = -s $node_subscriber->logfile;
+
+# Insert tuples on publisher again. This transaction is independent from the
+# previous one, but the parallel worker would wait till it finishes
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(41, 50), 'test');");
+
+# Verify the parallel worker waits for the transaction
+my $str = $node_subscriber->wait_for_log(qr/wait for depended xid ([1-9][0-9]+)/, $offset);
+my $xid = $str =~ /wait for depended xid ([1-9][0-9]+)/;
+
+# Update tuples which have not been applied yet on subscriber because the
+# parallel worker stops at the injection point. Newly assigned worker also
+# waits for the same transactions as above.
+$node_publisher->safe_psql('postgres',
+    "UPDATE regress_tab SET value = 'updated' WHERE id BETWEEN 31 AND 35;");
+
+# Verify the dependency is detected for the update
+$node_subscriber->wait_for_log(qr/found conflicting replica identity change from $xid/, $offset);
+
+# Verify the parallel worker waits for the same transaction
+$node_subscriber->wait_for_log(qr/wait for depended xid $xid/, $offset);
+
+# Wakeup the parallel worker. We detach first no to stop other parallel workers
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+# Verify the parallel worker wakes up
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab");
+is ($result, 50, 'inserts are replicated to subscriber');
+
+$result =
+  $node_subscriber->safe_psql('postgres',
+    "SELECT count(1) FROM regress_tab WHERE value = 'updated'");
+is ($result, 5, 'updates are also replicated to subscriber');
+
+##################################################
+# Test that the dependency tracking works correctly for unchanged toasted RI
+# columns.
+##################################################
+
+# Attach an injection_point. Parallel workers would wait before the commit
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+# Update one replica identity column but keep toasted column unchanged
+$node_publisher->safe_psql('postgres',
+    "UPDATE tab_toast SET b = '1';");
+
+# Wait until the parallel worker enters the injection point.
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+# Delete the updated row.
+$node_publisher->safe_psql('postgres',
+    "DELETE FROM tab_toast WHERE b = '1';");
+
+# Verify the dependency is detected for the delete
+$str = $node_subscriber->wait_for_log(qr/found conflicting replica identity change from ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /found conflicting replica identity change from ([1-9][0-9]+)/;
+
+# Verify the parallel worker waits for the same transaction
+$node_subscriber->wait_for_log(qr/wait for depended xid $xid/, $offset);
+
+# Wakeup the parallel worker. We detach first no to stop other parallel workers
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+# Verify the parallel worker wakes up
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM tab_toast");
+is ($result, 0, 'changes are replicated to subscriber');
+
+done_testing();
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 07d246bd957..db8bb4e1e43 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2162,6 +2162,7 @@ ParallelHashGrowth
 ParallelHashJoinBatch
 ParallelHashJoinBatchAccessor
 ParallelHashJoinState
+ParallelizedTxnEntry
 ParallelIndexScanDesc
 ParallelizedTxnEntry
 ParallelSlot
@@ -4257,6 +4258,7 @@ rendezvousHashEntry
 rep
 replace_rte_variables_callback
 replace_rte_variables_context
+replica_identity_hash
 report_error_fn
 ret_type
 rewind_source
-- 
2.43.0



  [application/octet-stream] v19-0002-Introduce-internal-messages-to-track-dependencie.patch (12.7K, ../../TY4PR01MB17718A4AB7F564C8DB0D6F7DC94102@TY4PR01MB17718.jpnprd01.prod.outlook.com/10-v19-0002-Introduce-internal-messages-to-track-dependencie.patch)
  download | inline diff:
From baaa11fcd78973709c41c898ad22484d09ab4718 Mon Sep 17 00:00:00 2001
From: Zhijie Hou <[email protected]>
Date: Wed, 29 Apr 2026 16:41:44 +0800
Subject: [PATCH v19 2/3] Introduce internal messages to track dependencies

This patch introduces a new set of internal worker message types (see
PAWorkerMsgType). These types of messages are generated by a leader worker
and sent to parallel apply workers based on the needs. For now, two types of
messages exist: PA_MSG_XACT_DEPENDENCY and PA_MSG_RELMAP.

PA_MSG_XACT_DEPENDENCY ensures that dependent transactions are
committed in the correct order. It has a list of transaction IDs that parallel
workers must wait for. This type of message is generated when the leader
detects a dependency between the current and other transactions, or just before
the COMMIT message. The latter one is used to preserve the commit ordering
between the publisher and the subscriber.

PA_MSG_RELMAP is used to synchronize the remote relation
information between the leader and parallel workers. It has a list of relations
that the leader already knows, and parallel workers also update the relmap in
response to the message. This type of message is generated when the leader
allocates a new parallel worker to the transaction, or when the publisher sends
additional RELATION messages. This synchronization is necessary for parallel
apply workers to map local replication target relations to their remote
counterparts during change application. Since the walsender does not send remote
relation information with every transaction, the parallel apply worker may not
have up-to-date relation info unless synchronized by the leader.

In the logical replication protocol, the above messages are encapsulated within
the LOGICAL_REP_MSG_INTERNAL_MESSAGE type. Later patches will use a uniform
format (LOGICAL_REP_MSG_INTERNAL_MESSAGE + PAWorkerMsgType + internal data) for
sending to parallel apply workers or serializing to disk.

Author: Zhijie Hou <[email protected]>
Author: Hayato Kuroda <[email protected]>
---
 .../replication/logical/applyparallelworker.c | 187 ++++++++++++++++--
 src/backend/replication/logical/proto.c       |   2 +
 src/backend/replication/logical/worker.c      |   4 +
 src/include/replication/logicalproto.h        |   8 +
 src/include/replication/worker_internal.h     |  17 ++
 src/tools/pgindent/typedefs.list              |   1 +
 6 files changed, 202 insertions(+), 17 deletions(-)

diff --git a/src/backend/replication/logical/applyparallelworker.c b/src/backend/replication/logical/applyparallelworker.c
index 109d85c7cdf..5f622bbe558 100644
--- a/src/backend/replication/logical/applyparallelworker.c
+++ b/src/backend/replication/logical/applyparallelworker.c
@@ -772,6 +772,73 @@ ProcessParallelApplyInterrupts(void)
 	}
 }
 
+/*
+ * Handle internal dependency information.
+ *
+ * Wait for all transactions listed in the message to commit.
+ */
+static void
+apply_handle_internal_dependency(StringInfo s)
+{
+	int			nxids = pq_getmsgint(s, 4);
+
+	for (int i = 0; i < nxids; i++)
+	{
+		TransactionId xid = pq_getmsgint(s, 4);
+
+		pa_wait_for_depended_transaction(xid);
+	}
+}
+
+/*
+ * Handle internal relation information.
+ *
+ * Update all relation details in the relation map cache.
+ */
+static void
+apply_handle_internal_relation(StringInfo s)
+{
+	int			nrels = pq_getmsgint(s, 4);
+
+	for (int i = 0; i < nrels; i++)
+	{
+		LogicalRepRelation *rel = logicalrep_read_rel(s);
+
+		logicalrep_relmap_update(rel);
+
+		/* Also reset all entries in the partition map that refer to remoterel. */
+		logicalrep_partmap_reset_relmap(rel);
+
+		elog(DEBUG1, "parallel apply worker init relmap for %s",
+			 rel->relname);
+	}
+}
+
+/*
+ * Handle an internal message generated by the leader apply worker.
+ */
+void
+apply_handle_internal_message(StringInfo s)
+{
+	PAWorkerMsgType action = pq_getmsgbyte(s);
+
+	Assert(am_parallel_apply_worker());
+
+	switch (action)
+	{
+		case PA_MSG_XACT_DEPENDENCY:
+			apply_handle_internal_dependency(s);
+			break;
+		case PA_MSG_RELMAP:
+			apply_handle_internal_relation(s);
+			break;
+		default:
+			ereport(ERROR,
+					(errcode(ERRCODE_PROTOCOL_VIOLATION),
+					 errmsg("invalid worker internal message type \"??? (%d)\"", action)));
+	}
+}
+
 /* Parallel apply worker main loop. */
 static void
 LogicalParallelApplyLoop(shm_mq_handle *mqh)
@@ -780,6 +847,14 @@ LogicalParallelApplyLoop(shm_mq_handle *mqh)
 	ErrorContextCallback errcallback;
 	MemoryContext oldcxt = CurrentMemoryContext;
 
+	/*
+	 * Ensure LOGICAL_REP_MSG_INTERNAL_MESSAGE does not conflict with
+	 * PqReplMsg_WALData ('d'), as parallel apply workers may receive both types
+	 * of messages.
+	 */
+	StaticAssertDecl(LOGICAL_REP_MSG_INTERNAL_MESSAGE != PqReplMsg_WALData,
+					 "LOGICAL_REP_MSG_INTERNAL_MESSAGE conflicts with PqReplMsg_WALData");
+
 	/*
 	 * Init the ApplyMessageContext which we clean up after each replication
 	 * protocol message.
@@ -818,26 +893,46 @@ LogicalParallelApplyLoop(shm_mq_handle *mqh)
 
 			initReadOnlyStringInfo(&s, data, len);
 
-			/*
-			 * The first byte of messages sent from leader apply worker to
-			 * parallel apply workers can only be PqReplMsg_WALData.
-			 */
 			c = pq_getmsgbyte(&s);
-			if (c != PqReplMsg_WALData)
-				elog(ERROR, "unexpected message \"%c\"", c);
 
-			/*
-			 * Ignore statistics fields that have been updated by the leader
-			 * apply worker.
-			 *
-			 * XXX We can avoid sending the statistics fields from the leader
-			 * apply worker but for that, it needs to rebuild the entire
-			 * message by removing these fields which could be more work than
-			 * simply ignoring these fields in the parallel apply worker.
-			 */
-			s.cursor += SIZE_STATS_MESSAGE;
+			if (c == PqReplMsg_WALData)
+			{
+				/*
+				 * Ignore statistics fields that have been updated by the
+				 * leader apply worker.
+				 *
+				 * XXX We can avoid sending the statistics fields from the
+				 * leader apply worker but for that, it needs to rebuild the
+				 * entire message by removing these fields which could be more
+				 * work than simply ignoring these fields in the parallel apply
+				 * worker.
+				 */
+				s.cursor += SIZE_STATS_MESSAGE;
+
+				apply_dispatch(&s);
+			}
+			else if (c == LOGICAL_REP_MSG_INTERNAL_MESSAGE)
+			{
+				/*
+				 * Rewind the cursor so that apply_dispatch can re-read the
+				 * first byte (LOGICAL_REP_MSG_INTERNAL_MESSAGE) to correctly
+				 * handle the internal message. Alternatively, we could call
+				 * apply_handle_internal_message directly, but using
+				 * apply_dispatch uniformly across all message types is cleaner
+				 * and more consistent.
+				 */
+				s.cursor--;
 
-			apply_dispatch(&s);
+				apply_dispatch(&s);
+			}
+			else
+			{
+				/*
+				 * The first byte of messages sent from leader apply worker to
+				 * parallel apply workers can only be 'w' or 'i'.
+				 */
+				elog(ERROR, "unexpected message \"%c\"", c);
+			}
 		}
 		else if (shmq_res == SHM_MQ_WOULD_BLOCK)
 		{
@@ -1744,3 +1839,61 @@ pa_attach_parallelized_txn_hash(dsa_handle *pa_dsa_handle,
 
 	MemoryContextSwitchTo(oldctx);
 }
+
+/*
+ * Wait for the given remote transaction to finish applying by a parallel apply
+ * worker.
+ *
+ * Both leader and parallel apply workers can call this function to wait for a
+ * parallelized transaction to finish.
+ */
+void
+pa_wait_for_depended_transaction(TransactionId xid)
+{
+	ParallelizedTxnEntry *txn_entry;
+
+	/*
+	 * Quick exit if parallelized_txns has not been initialized yet. This can
+	 * happen when the leader worker calls this function before any parallel
+	 * apply workers have been launched.
+	 */
+	if (!parallelized_txns)
+		return;
+
+	elog(DEBUG1, "wait for depended xid %u", xid);
+
+	for (;;)
+	{
+		txn_entry = dshash_find(parallelized_txns, &xid, false);
+
+		/* The entry is removed only if the transaction is committed */
+		if (txn_entry == NULL)
+			break;
+
+		dshash_release_lock(parallelized_txns, txn_entry);
+
+		/*
+		 * Wait for the parallel apply worker processing the given remote
+		 * transaction to finish applying and release its lock.
+		 */
+		pa_lock_transaction(xid, AccessShareLock);
+		pa_unlock_transaction(xid, AccessShareLock);
+
+		CHECK_FOR_INTERRUPTS();
+
+		/*
+		 * Acquiring the lock successfully does not guarantee we can proceed.
+		 * The worker may have errored out and released the lock while leaving
+		 * its shared hash entry intact, or it may not have acquired the lock
+		 * yet because it hasn't processed the BEGIN message. In either case, we
+		 * must continue waiting in the loop until the parallel apply worker
+		 * finishes applying the transaction, or until the leader notifies us of
+		 * a failure and restarts all workers.
+		 *
+		 * The above race window is small and infrequent, so no WaitLatch is
+		 * added.
+		 */
+	}
+
+	elog(DEBUG1, "finish waiting for depended xid %u", xid);
+}
diff --git a/src/backend/replication/logical/proto.c b/src/backend/replication/logical/proto.c
index 86ad97cd937..a0cac05cdcd 100644
--- a/src/backend/replication/logical/proto.c
+++ b/src/backend/replication/logical/proto.c
@@ -1253,6 +1253,8 @@ logicalrep_message_type(LogicalRepMsgType action)
 			return "STREAM ABORT";
 		case LOGICAL_REP_MSG_STREAM_PREPARE:
 			return "STREAM PREPARE";
+		case LOGICAL_REP_MSG_INTERNAL_MESSAGE:
+			return "INTERNAL MESSAGE";
 	}
 
 	/*
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index a3f2406ed83..6599d563306 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -3890,6 +3890,10 @@ apply_dispatch(StringInfo s)
 			apply_handle_stream_prepare(s);
 			break;
 
+		case LOGICAL_REP_MSG_INTERNAL_MESSAGE:
+			apply_handle_internal_message(s);
+			break;
+
 		default:
 			ereport(ERROR,
 					(errcode(ERRCODE_PROTOCOL_VIOLATION),
diff --git a/src/include/replication/logicalproto.h b/src/include/replication/logicalproto.h
index 058a955e20c..cb0a8f440c0 100644
--- a/src/include/replication/logicalproto.h
+++ b/src/include/replication/logicalproto.h
@@ -53,6 +53,13 @@
  * in logical replication protocol, which uses a single byte to identify a
  * message type. Hence the values should be single-byte wide and preferably
  * human-readable characters.
+ *
+ * LOGICAL_REP_MSG_INTERNAL_MESSAGE ('i') is reserved for internal messages (see
+ * PAWorkerMsgType for sub-message types) sent from the leader apply
+ * worker to parallel apply workers. Centralizing this definition here allows
+ * all message types to be handled together and avoids the maintenance burden of
+ * ensuring sub-message types do not conflict with regular LogicalRepMsgType
+ * values.
  */
 typedef enum LogicalRepMsgType
 {
@@ -75,6 +82,7 @@ typedef enum LogicalRepMsgType
 	LOGICAL_REP_MSG_STREAM_COMMIT = 'c',
 	LOGICAL_REP_MSG_STREAM_ABORT = 'A',
 	LOGICAL_REP_MSG_STREAM_PREPARE = 'p',
+	LOGICAL_REP_MSG_INTERNAL_MESSAGE = 'i',
 } LogicalRepMsgType;
 
 /*
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index e60e0c3d6d7..43dcae43a62 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -242,6 +242,19 @@ typedef struct ParallelApplyWorkerInfo
 	ParallelApplyWorkerShared *shared;
 } ParallelApplyWorkerInfo;
 
+/*
+ * Parallel apply worker internal message types.
+ *
+ * These types of messages are generated by the leader apply worker and sent to
+ * the parallel apply worker, encapsulated within the
+ * LOGICAL_REP_MSG_INTERNAL_MESSAGE type.
+ */
+typedef enum PAWorkerMsgType
+{
+	PA_MSG_XACT_DEPENDENCY = 'd',
+	PA_MSG_RELMAP = 'r',
+} PAWorkerMsgType;
+
 /* Main memory context for apply worker. Permanent during worker lifetime. */
 extern PGDLLIMPORT MemoryContext ApplyContext;
 
@@ -341,6 +354,8 @@ extern void pa_allocate_worker(TransactionId xid);
 extern ParallelApplyWorkerInfo *pa_find_worker(TransactionId xid);
 extern void pa_detach_all_error_mq(void);
 
+extern void apply_handle_internal_message(StringInfo s);
+
 extern bool pa_send_data(ParallelApplyWorkerInfo *winfo, Size nbytes,
 						 const void *data);
 extern void pa_switch_to_partial_serialize(ParallelApplyWorkerInfo *winfo,
@@ -368,6 +383,8 @@ extern void pa_decr_and_wait_stream_block(void);
 extern void pa_xact_finish(ParallelApplyWorkerInfo *winfo,
 						   XLogRecPtr remote_lsn);
 
+extern void pa_wait_for_depended_transaction(TransactionId xid);
+
 #define isParallelApplyWorker(worker) ((worker)->in_use && \
 									   (worker)->type == WORKERTYPE_PARALLEL_APPLY)
 #define isTableSyncWorker(worker) ((worker)->in_use && \
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index fabda17d739..c00c34652fe 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -1926,6 +1926,7 @@ OverridingKind
 PACE_HEADER
 PACL
 PATH
+PAWorkerMsgType
 PCtxtHandle
 PERL_CONTEXT
 PERL_SI
-- 
2.43.0



  [application/octet-stream] v19-0003-Introduce-a-local-hash-table-to-store-replica-id.patch (34.7K, ../../TY4PR01MB17718A4AB7F564C8DB0D6F7DC94102@TY4PR01MB17718.jpnprd01.prod.outlook.com/11-v19-0003-Introduce-a-local-hash-table-to-store-replica-id.patch)
  download | inline diff:
From 7288605c496764047ce0fc550ddd32c28bd4a08d Mon Sep 17 00:00:00 2001
From: Zhijie Hou <[email protected]>
Date: Thu, 30 Apr 2026 13:28:40 +0800
Subject: [PATCH v19 3/3] Introduce a local hash table to store replica
 identities

This local hash table on the leader is used for detecting dependencies between
transactions.

The hash contains the Replica Identity (RI) as a key and the remote XID that
modified the corresponding tuple. The hash entries are inserted when the leader
finds an RI from a replication message, and are removed in the
following cases:

1) When handling a subsequent change that modifies the same row, and the
   stored transaction has already committed.
2) When collecting flush progress of remote transactions, and the stored
   transaction is found to have committed.
3) When the number of entries exceeds REPLICA_IDENTITY_CLEANUP_THRESHOLD
   and the stored transaction has already committed.

When the leader sends replication changes to parallel workers, it checks whether
aother transaction has already used the RI associated with the change. If so,
the leader marks the current transaction as dependent on that transaction and
notifies parallel workers via PA_MSG_XACT_DEPENDENCY to wait for that
transaction to finish.

Author: Zhijie Hou <[email protected]>
Author: Hayato Kuroda <[email protected]>
---
 .../replication/logical/applyparallelworker.c | 131 +++-
 src/backend/replication/logical/relation.c    |  24 +
 src/backend/replication/logical/worker.c      | 688 +++++++++++++++++-
 src/include/replication/logicalrelation.h     |   9 +
 src/include/replication/worker_internal.h     |   6 +-
 src/test/subscription/meson.build             |   1 +
 src/tools/pgindent/typedefs.list              |   3 +
 7 files changed, 857 insertions(+), 5 deletions(-)

diff --git a/src/backend/replication/logical/applyparallelworker.c b/src/backend/replication/logical/applyparallelworker.c
index 5f622bbe558..ff4d6028ddc 100644
--- a/src/backend/replication/logical/applyparallelworker.c
+++ b/src/backend/replication/logical/applyparallelworker.c
@@ -213,12 +213,32 @@
 #define PARALLEL_APPLY_LOCK_XACT	1
 
 /*
- * Hash table entry to map xid to the parallel apply worker state.
+ * Hash table entry of ParallelApplyTxnHash to map xid to the parallel apply
+ * worker state.
  */
 typedef struct ParallelApplyWorkerEntry
 {
-	TransactionId xid;			/* Hash key -- must be first */
+	TransactionId xid;			/* Remote transaction ID (hash key) ---
+								 * must be first */
+
+	/*
+	 * The parallel apply worker assigned for applying the transaction. It is
+	 * NULL if the worker has already finished the transaction and the leader
+	 * has collected its local end LSN.
+	 */
 	ParallelApplyWorkerInfo *winfo;
+
+	/*
+	 * The local end LSN of this transaction applied by the parallel apply
+	 * worker.
+	 *
+	 * The leader will initialize this value by reading the last_commit_end from
+	 * the parallel apply worker shared memory before reusing the worker for
+	 * another transaction (see pa_get_last_commit_end). An invalid value
+	 * indicates the worker has not finished applying the transaction or there
+	 * is no change applied by the worker for this transaction.
+	 */
+	XLogRecPtr	local_end;
 } ParallelApplyWorkerEntry;
 
 /* An entry in the parallelized_txns shared hash table */
@@ -230,6 +250,10 @@ typedef struct ParallelizedTxnEntry
 /*
  * A hash table used to cache the state of streaming transactions being applied
  * by the parallel apply workers. Entries are of type ParallelApplyWorkerEntry.
+ *
+ * The leader apply worker adds an entry when a transaction starts being
+ * applied, and removes it after collecting the transaction's local end LSN from
+ * the parallel worker.
  */
 static HTAB *ParallelApplyTxnHash = NULL;
 
@@ -237,6 +261,10 @@ static HTAB *ParallelApplyTxnHash = NULL;
  * A hash table used to track the parallelized remote transactions that could be
  * depended on by other transactions. Entries are of type ParallelizedTxnEntry.
  *
+ * The leader apply worker adds an entry before sending the COMMIT record of a
+ * transaction to a parallel apply worker. The entry is removed by that worker
+ * immediately after committing the transaction.
+ *
  * dshash is used to enable dynamic shared memory allocation based on the number
  * of transactions being applied in parallel.
  */
@@ -510,7 +538,7 @@ pa_launch_parallel_worker(void)
  * streaming changes.
  */
 void
-pa_allocate_worker(TransactionId xid)
+pa_allocate_worker(TransactionId xid, bool stream_txn)
 {
 	bool		found;
 	ParallelApplyWorkerInfo *winfo = NULL;
@@ -551,7 +579,9 @@ pa_allocate_worker(TransactionId xid)
 
 	winfo->in_use = true;
 	winfo->serialize_changes = false;
+	winfo->stream_txn = stream_txn;
 	entry->winfo = winfo;
+	entry->local_end = InvalidXLogRecPtr;
 }
 
 /*
@@ -748,6 +778,72 @@ pa_process_spooled_messages_if_required(void)
 	return true;
 }
 
+/*
+ * Get the local end LSN for a transaction applied by a parallel apply worker.
+ *
+ * If the parallel apply worker did not write any changes during the transaction
+ * application due to situations like update/delete_missing or a before trigger,
+ * the *skipped_write will be set to true.
+ *
+ * Once the LSN is retrieved, the transaction entry is removed from
+ * ParallelApplyTxnHash.
+ */
+XLogRecPtr
+pa_get_last_commit_end(TransactionId xid, bool *skipped_write)
+{
+	bool		found;
+	ParallelApplyWorkerEntry *entry;
+	ParallelApplyWorkerInfo *winfo;
+
+	Assert(TransactionIdIsValid(xid));
+	Assert(ParallelApplyTxnHash);
+
+	if (skipped_write)
+		*skipped_write = false;
+
+	/* Find an entry for the requested transaction. */
+	entry = hash_search(ParallelApplyTxnHash, &xid, HASH_FIND, &found);
+
+	if (!found)
+		return InvalidXLogRecPtr;
+
+	/*
+	 * If worker info is NULL, it indicates that the worker has already
+	 * finished handling the transaction. Consequently, the local end LSN has
+	 * already been collected and saved in entry->local_end.
+	 */
+	winfo = entry->winfo;
+	if (winfo == NULL)
+	{
+		if (!hash_search(ParallelApplyTxnHash, &xid, HASH_REMOVE, NULL))
+			elog(ERROR, "hash table corrupted");
+
+		if (skipped_write)
+			*skipped_write = XLogRecPtrIsInvalid(entry->local_end);
+
+		return entry->local_end;
+	}
+
+	/* Return InvalidXLogRecPtr if the transaction is still in progress */
+	if (pa_get_xact_state(winfo->shared) != PARALLEL_TRANS_FINISHED)
+		return InvalidXLogRecPtr;
+
+	/* Collect the local end LSN from the worker's shared memory area */
+	entry->local_end = winfo->shared->last_commit_end;
+	entry->winfo = NULL;
+
+	if (skipped_write)
+		*skipped_write = XLogRecPtrIsInvalid(entry->local_end);
+
+	elog(DEBUG1, "store local commit %X/%X end to txn entry: %u",
+		 LSN_FORMAT_ARGS(entry->local_end), xid);
+
+	if (!hash_search(ParallelApplyTxnHash, &xid, HASH_REMOVE, NULL))
+		elog(ERROR, "hash table corrupted");
+
+	return entry->local_end;
+}
+
 /*
  * Interrupt handler for main loop of parallel apply worker.
  */
@@ -1795,6 +1891,35 @@ pa_xact_finish(ParallelApplyWorkerInfo *winfo, XLogRecPtr remote_lsn)
 	pa_free_worker(winfo);
 }
 
+/*
+ * Check if the given transaction has been committed. This can only be used by
+ * the leader apply worker.
+ *
+ * Returns true if the transaction is committed, false if it is still being
+ * applied in parallel.
+ */
+bool
+pa_transaction_committed(TransactionId xid)
+{
+	bool		found;
+	ParallelApplyWorkerEntry *entry;
+
+	Assert(am_leader_apply_worker());
+	Assert(TransactionIdIsValid(xid));
+	Assert(ParallelApplyTxnHash);
+
+	/* Find an entry for the requested transaction */
+	entry = hash_search(ParallelApplyTxnHash, &xid, HASH_FIND, &found);
+
+	if (!found)
+		return true;
+
+	if (!entry->winfo)
+		return true;
+
+	return pa_get_xact_state(entry->winfo->shared) == PARALLEL_TRANS_FINISHED;
+}
+
 /*
  * Attach to the shared hash table for parallelized transactions.
  */
diff --git a/src/backend/replication/logical/relation.c b/src/backend/replication/logical/relation.c
index 0b1d80b5b0f..ab6313eb1bc 100644
--- a/src/backend/replication/logical/relation.c
+++ b/src/backend/replication/logical/relation.c
@@ -960,3 +960,27 @@ FindLogicalRepLocalIndex(Relation localrel, LogicalRepRelation *remoterel,
 
 	return InvalidOid;
 }
+
+/*
+ * Get the LogicalRepRelMapEntry corresponding to the given relid without
+ * opening the local relation.
+ */
+LogicalRepRelMapEntry *
+logicalrep_get_relentry(LogicalRepRelId remoteid)
+{
+	LogicalRepRelMapEntry *entry;
+	bool		found;
+
+	if (LogicalRepRelMap == NULL)
+		logicalrep_relmap_init();
+
+	/* Search for existing entry. */
+	entry = hash_search(LogicalRepRelMap, (void *) &remoteid,
+						HASH_FIND, &found);
+
+	if (!found)
+		elog(DEBUG1, "no relation map entry for remote relation ID %u",
+			 remoteid);
+
+	return entry;
+}
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 6599d563306..eea870ca282 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -303,11 +303,35 @@
 
 #define NAPTIME_PER_CYCLE 1000	/* max sleep time between cycles (1s) */
 
+/*
+ * Struct for tracking the progress of flushing the transaction received from
+ * the publisher.
+ *
+ * A list of these structs (lsn_mapping) is maintained in the apply worker, each
+ * representing a transaction that is being flushed. The entries are removed
+ * from the list when the transaction is fully flushed (see get_flush_position).
+ */
 typedef struct FlushPosition
 {
 	dlist_node	node;
+
+	/*
+	 * The end of commit record applied by the worker, and the corresponding end
+	 * of commit record on the publisher.
+	 *
+	 * For transactions assigned to a parallel apply worker, the local_end is
+	 * not immediately available. The leader will update the local_end when it
+	 * confirms that the parallel apply worker has finished applying the
+	 * transaction (see the usage of pa_get_last_commit_end).
+	 */
 	XLogRecPtr	local_end;
 	XLogRecPtr	remote_end;
+
+	/*
+	 * The remote transaction ID. This should be set to a valid ID only when the
+	 * transaction is assigned to a parallel apply worker.
+	 */
+	TransactionId pa_remote_xid;
 } FlushPosition;
 
 static dlist_head lsn_mapping = DLIST_STATIC_INIT(lsn_mapping);
@@ -476,6 +500,8 @@ ErrorContextCallback *apply_error_context_stack = NULL;
 MemoryContext ApplyMessageContext = NULL;
 MemoryContext ApplyContext = NULL;
 
+static MemoryContext ParallelApplyContext = NULL;
+
 /* per stream context for streaming transactions */
 static MemoryContext LogicalStreamingContext = NULL;
 
@@ -549,6 +575,65 @@ typedef struct ApplySubXactData
 
 static ApplySubXactData subxact_data = {0, 0, InvalidTransactionId, NULL};
 
+/* Hash table key for replica_identity_table */
+typedef struct ReplicaIdentityKey
+{
+	Oid			relid;
+	LogicalRepTupleData *data;
+} ReplicaIdentityKey;
+
+/* Hash table entry in replica_identity_table */
+typedef struct ReplicaIdentityEntry
+{
+	ReplicaIdentityKey *keydata;
+	TransactionId remote_xid;
+
+	/* needed for simplehash */
+	uint32		hash;
+	char		status;
+} ReplicaIdentityEntry;
+
+#include "common/hashfn.h"
+
+static uint32 hash_replica_identity(ReplicaIdentityKey *key);
+static bool hash_replica_identity_compare(ReplicaIdentityKey *a,
+										  ReplicaIdentityKey *b);
+
+/* Define parameters for replica identity hash table code generation. */
+#define SH_PREFIX		replica_identity
+#define SH_ELEMENT_TYPE	ReplicaIdentityEntry
+#define SH_KEY_TYPE		ReplicaIdentityKey *
+#define	SH_KEY			keydata
+#define SH_HASH_KEY(tb, key)	hash_replica_identity(key)
+#define SH_EQUAL(tb, a, b)		hash_replica_identity_compare(a, b)
+#define SH_STORE_HASH
+#define SH_GET_HASH(tb, a) (a)->hash
+#define	SH_SCOPE		static inline
+#define SH_DECLARE
+#define SH_DEFINE
+#include "lib/simplehash.h"
+
+#define REPLICA_IDENTITY_INITIAL_SIZE 128
+#define REPLICA_IDENTITY_CLEANUP_THRESHOLD 1024
+
+/*
+ * Hash table storing replica identity values for changes being applied in
+ * parallel, along with the latest transaction that modified each row.
+ *
+ * Entries are removed in the following cases:
+ *
+ * 1) When handling a subsequent change that modifies the same row, and the
+ *    stored transaction has already committed.
+ * 2) When collecting flush progress of remote transactions, and the stored
+ *    transaction is found to have committed.
+ * 3) When the number of entries exceeds REPLICA_IDENTITY_CLEANUP_THRESHOLD
+ *    and the stored transaction has already committed.
+ *
+ * We use simplehash for its efficiency (see comments atop of simplehash.h), as
+ * this hash table is accessed for every change in a transaction.
+ */
+static replica_identity_hash *replica_identity_table = NULL;
+
 static inline void subxact_filename(char *path, Oid subid, TransactionId xid);
 static inline void changes_filename(char *path, Oid subid, TransactionId xid);
 
@@ -636,6 +721,580 @@ static void set_wal_receiver_timeout(void);
 
 static void on_exit_clear_xact_state(int code, Datum arg);
 
+static void send_internal_dependencies(ParallelApplyWorkerInfo *winfo,
+									   List *depends_on_xids);
+
+/*
+ * Compute the hash value for entries in the replica_identity_table.
+ */
+static uint32
+hash_replica_identity(ReplicaIdentityKey *key)
+{
+	int			i;
+	uint32		hashkey = 0;
+
+	hashkey = hash_combine(hashkey, hash_uint32(key->relid));
+
+	for (i = 0; i < key->data->ncols; i++)
+	{
+		uint32		hkey;
+
+		if (key->data->colstatus[i] == LOGICALREP_COLUMN_NULL)
+			continue;
+
+		hkey = hash_any((const unsigned char *) key->data->colvalues[i].data,
+						key->data->colvalues[i].len);
+		hashkey = hash_combine(hashkey, hkey);
+	}
+
+	return hashkey;
+}
+
+/*
+ * Compare two entries in the replica_identity_table.
+ */
+static bool
+hash_replica_identity_compare(ReplicaIdentityKey *a, ReplicaIdentityKey *b)
+{
+	if (a->relid != b->relid ||
+		a->data->ncols != b->data->ncols)
+		return false;
+
+	for (int i = 0; i < a->data->ncols; i++)
+	{
+		if (a->data->colstatus[i] != b->data->colstatus[i])
+			return false;
+
+		if (a->data->colvalues[i].len != b->data->colvalues[i].len)
+			return false;
+
+		if (strcmp(a->data->colvalues[i].data, b->data->colvalues[i].data))
+			return false;
+
+		elog(DEBUG1, "conflicting key %s", a->data->colvalues[i].data);
+	}
+
+	return true;
+}
+
+/*
+ * Free resources associated with a replica identity key.
+ */
+static void
+free_replica_identity_key(ReplicaIdentityKey *key)
+{
+	Assert(key);
+
+	pfree(key->data->colvalues);
+	pfree(key->data->colstatus);
+	pfree(key->data);
+	pfree(key);
+}
+
+/*
+ * Clean up hash table entries associated with the given transaction IDs.
+ */
+static void
+delete_replica_identity_entries_for_txns(List *committed_xids)
+{
+	replica_identity_iterator i;
+	ReplicaIdentityEntry *rientry;
+
+	if (!committed_xids)
+		return;
+
+	replica_identity_start_iterate(replica_identity_table, &i);
+	while ((rientry = replica_identity_iterate(replica_identity_table, &i)) != NULL)
+	{
+		if (!list_member_xid(committed_xids, rientry->remote_xid))
+			continue;
+
+		/* Clean up the hash entry for committed transaction */
+		free_replica_identity_key(rientry->keydata);
+		replica_identity_delete_item(replica_identity_table, rientry);
+	}
+}
+
+/*
+ * Check committed transactions and clean up corresponding entries in the hash
+ * table.
+ *
+ * Also update the local_end for transactions assigned to parallel apply workers
+ * if not already set.
+ */
+static void
+cleanup_committed_replica_identity_entries(void)
+{
+	dlist_mutable_iter iter;
+	List	   *committed_xids = NIL;
+
+	dlist_foreach_modify(iter, &lsn_mapping)
+	{
+		FlushPosition *pos =
+			dlist_container(FlushPosition, node, iter.cur);
+		bool		skipped_write;
+
+		/*
+		 * Skip if the transaction was not assigned to a parallel worker, or if
+		 * we have already collected its local end LSN.
+		 */
+		if (!TransactionIdIsValid(pos->pa_remote_xid) ||
+			XLogRecPtrIsValid(pos->local_end))
+			continue;
+
+		pos->local_end = pa_get_last_commit_end(pos->pa_remote_xid,
+												&skipped_write);
+
+		elog(DEBUG1,
+			 "got commit end from parallel apply worker, "
+			 "txn: %u, remote_end %X/%X, local_end %X/%X",
+			 pos->pa_remote_xid, LSN_FORMAT_ARGS(pos->remote_end),
+			 LSN_FORMAT_ARGS(pos->local_end));
+
+		/* Skip if the transaction is still being applied */
+		if (!skipped_write && !XLogRecPtrIsValid(pos->local_end))
+			continue;
+
+		committed_xids = lappend_xid(committed_xids, pos->pa_remote_xid);
+	}
+
+	/* cleanup the entries for committed transactions */
+	delete_replica_identity_entries_for_txns(committed_xids);
+}
+
+/*
+ * Append the given transaction ID to the dependency list if it is not already
+ * present.
+ *
+ * If the transaction has already committed, the ID is reset to
+ * InvalidTransactionId and nothing is appended.
+ */
+static void
+check_and_append_xid_dependency(TransactionId *xid, List **depends_on_xids)
+{
+	Assert(xid && TransactionIdIsValid(*xid));
+
+	if (list_member_xid(*depends_on_xids, *xid))
+		return;
+
+	/*
+	 * Return and reset the xid if the transaction has been committed.
+	 */
+	if (pa_transaction_committed(*xid))
+	{
+		*xid = InvalidTransactionId;
+		return;
+	}
+
+	*depends_on_xids = lappend_xid(*depends_on_xids, *xid);
+}
+
+/*
+ * Check for dependencies on preceding transactions that modify the same key.
+ * Returns the dependent transactions in 'depends_on_xids'.
+ *
+ * Additionally, if new_depended_xid is valid, record it as a dependency for the
+ * replica identity key modification, allowing subsequent transactions that
+ * modify the same key to be dependent on it.
+ */
+static void
+check_and_record_ri_dependency(Oid relid, LogicalRepTupleData *original_data,
+							   TransactionId new_depended_xid,
+							   List **depends_on_xids)
+{
+	LogicalRepRelMapEntry *relentry;
+	LogicalRepTupleData *ridata;
+	ReplicaIdentityKey *rikey;
+	ReplicaIdentityEntry *rientry;
+	MemoryContext oldctx;
+	int			n_ri;
+	bool		found = false;
+
+	Assert(depends_on_xids);
+
+	/* Search for existing entry */
+	relentry = logicalrep_get_relentry(relid);
+
+	Assert(relentry);
+
+	/*
+	 * First check whether any previous transaction (other than the current one)
+	 * has affected the whole table e.g., truncate or schema change from
+	 * publisher.
+	 */
+	if (TransactionIdIsValid(relentry->last_depended_xid) &&
+		!TransactionIdEquals(relentry->last_depended_xid, new_depended_xid))
+		check_and_append_xid_dependency(&relentry->last_depended_xid, depends_on_xids);
+
+	n_ri = bms_num_members(relentry->remoterel.attkeys);
+
+	/*
+	 * Return if there are no replica identity columns, indicating that the
+	 * remote relation has neither a replica identity key nor is marked as
+	 * replica identity full.
+	 */
+	if (!n_ri)
+		return;
+
+	/* Check if the RI key value of the tuple is invalid */
+	for (int i = 0; i < original_data->ncols; i++)
+	{
+		if (!bms_is_member(i, relentry->remoterel.attkeys))
+			continue;
+
+		/*
+		 * NULL in the new tuple means the replica identity key hasn't changed,
+		 * so no new dependency needs to be recorded. The dependency should have
+		 * been recorded when processing the old tuple.
+		 */
+		if (original_data->colstatus[i] == LOGICALREP_COLUMN_NULL)
+			return;
+
+		/*
+		 * LOGICALREP_COLUMN_UNCHANGED only indicates that a TOAST column in the
+		 * replica identity key hasn't changed. However, other columns may have
+		 * changed, so we still need to check the dependency for this column.
+		 *
+		 * Before calling this function, unchanged TOAST column values should
+		 * have been copied from the old tuple to the new tuple. So, we should
+		 * see the complete replica identity key value in original_data and
+		 * correctly check the dependency.
+		 */
+		Assert(original_data->colstatus[i] != LOGICALREP_COLUMN_UNCHANGED ||
+			   original_data->colvalues[i].len > 0);
+	}
+
+	oldctx = MemoryContextSwitchTo(ApplyContext);
+
+	/* Allocate space for replica identity values */
+	ridata = palloc0_object(LogicalRepTupleData);
+	ridata->colvalues = palloc0_array(StringInfoData, n_ri);
+	ridata->colstatus = palloc0_array(char, n_ri);
+	ridata->ncols = n_ri;
+
+	for (int i_original = 0, i_ri = 0; i_original < original_data->ncols; i_original++)
+	{
+		StringInfo	original_colvalue = &original_data->colvalues[i_original];
+
+		if (!bms_is_member(i_original, relentry->remoterel.attkeys))
+			continue;
+
+		initStringInfoExt(&ridata->colvalues[i_ri], original_colvalue->len + 1);
+		appendStringInfoString(&ridata->colvalues[i_ri], original_colvalue->data);
+		ridata->colstatus[i_ri] = original_data->colstatus[i_original];
+		i_ri++;
+	}
+
+	rikey = palloc0_object(ReplicaIdentityKey);
+	rikey->relid = relid;
+	rikey->data = ridata;
+
+	MemoryContextSwitchTo(oldctx);
+
+	/*
+	 * The new xid could be invalid if the transaction will be applied by the
+	 * leader itself which means all the changes will be committed before
+	 * processing next transaction. In this case, we only need to check for
+	 * dependencies on preceding transactions, there is no need to record a new
+	 * dependency for subsequent transactions to wait on.
+	 */
+	if (!TransactionIdIsValid(new_depended_xid))
+	{
+		rientry = replica_identity_lookup(replica_identity_table, rikey);
+		free_replica_identity_key(rikey);
+
+		/* No dependency detected */
+		if (!rientry)
+			return;
+
+		elog(DEBUG1, "found conflicting replica identity change from %u",
+			 rientry->remote_xid);
+
+		Assert(TransactionIdIsValid(rientry->remote_xid));
+		check_and_append_xid_dependency(&rientry->remote_xid, depends_on_xids);
+
+		/*
+		 * Remove the entry if the remote transaction has been committed locally
+		 * and no new dependency needs to be added.
+		 */
+		if (!TransactionIdIsValid(rientry->remote_xid))
+		{
+			free_replica_identity_key(rientry->keydata);
+			replica_identity_delete_item(replica_identity_table, rientry);
+		}
+
+		return;
+	}
+
+	/* Record a new dependency for subsequent transactions to wait on */
+	rientry = replica_identity_insert(replica_identity_table, rikey,
+									  &found);
+
+	/*
+	 * Release the key built to search the entry, if the entry already exists.
+	 */
+	if (found)
+	{
+		Assert(TransactionIdIsValid(rientry->remote_xid));
+
+		elog(DEBUG1, "found conflicting replica identity change from %u",
+			 rientry->remote_xid);
+
+		free_replica_identity_key(rikey);
+
+		/*
+		 * Return if the current transaction was the last one to modify the
+		 * key.
+		 */
+		if (TransactionIdEquals(rientry->remote_xid, new_depended_xid))
+			return;
+
+		check_and_append_xid_dependency(&rientry->remote_xid, depends_on_xids);
+	}
+
+	/* Update the new depended xid into the entry */
+	rientry->remote_xid = new_depended_xid;
+}
+
+/*
+ * Check for preceding transactions (other than current one 'new_depended_xid')
+ * that involve insert, delete, or update operations on the specified table, and
+ * return them in 'depends_on_xids'.
+ */
+static void
+find_all_dependencies_on_rel(LogicalRepRelId relid, TransactionId new_depended_xid,
+							 List **depends_on_xids)
+{
+	replica_identity_iterator i;
+	ReplicaIdentityEntry *rientry;
+
+	Assert(depends_on_xids);
+
+	replica_identity_start_iterate(replica_identity_table, &i);
+	while ((rientry = replica_identity_iterate(replica_identity_table, &i)) != NULL)
+	{
+		Assert(TransactionIdIsValid(rientry->remote_xid));
+
+		if (rientry->keydata->relid != relid)
+			continue;
+
+		/* Skip self-dependency */
+		if (TransactionIdEquals(rientry->remote_xid, new_depended_xid))
+			continue;
+
+		/* Clean up the hash entry for committed transaction while on it */
+		if (pa_transaction_committed(rientry->remote_xid))
+		{
+			free_replica_identity_key(rientry->keydata);
+			replica_identity_delete_item(replica_identity_table, rientry);
+
+			continue;
+		}
+
+		check_and_append_xid_dependency(&rientry->remote_xid, depends_on_xids);
+	}
+}
+
+/*
+ * Check for any preceding transactions that affect the given table and returns
+ * them in 'depends_on_xids'.
+ *
+ * Additionally, if new_depended_xid is valid, record it as a table-level
+ * dependency, allowing subsequent transactions that modify the same table to be
+ * dependent on it.
+ */
+static void
+check_and_record_rel_dependency(LogicalRepRelId relid,
+								TransactionId new_depended_xid,
+								List **depends_on_xids)
+{
+	LogicalRepRelMapEntry *relentry;
+
+	Assert(depends_on_xids);
+
+	find_all_dependencies_on_rel(relid, new_depended_xid, depends_on_xids);
+
+	/* Search for existing entry */
+	relentry = logicalrep_get_relentry(relid);
+
+	/*
+	 * Return if no entry exists, or if no transaction has affected the whole
+	 * table, or if the current transaction was the last one to modify it.
+	 */
+	if (!relentry ||
+		!TransactionIdIsValid(relentry->last_depended_xid) ||
+		TransactionIdEquals(relentry->last_depended_xid, new_depended_xid))
+		return;
+
+	check_and_append_xid_dependency(&relentry->last_depended_xid, depends_on_xids);
+
+	if (TransactionIdIsValid(new_depended_xid))
+		relentry->last_depended_xid = new_depended_xid;
+}
+
+/*
+ * Check dependencies related to the current change by determining if the
+ * modification impacts the same row or table as another ongoing transaction. If
+ * needed, instruct parallel apply workers to wait for these preceding
+ * transactions to complete.
+ *
+ * Simultaneously, if the current change will be dispatched to a parallel apply
+ * worker (indicated by a valid new_depended_xid and a non-NULL winfo), track
+ * the dependency for the current change to ensure that subsequent transactions
+ * address this dependency.
+ */
+static void
+handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
+							TransactionId new_depended_xid,
+							ParallelApplyWorkerInfo *winfo)
+{
+	LogicalRepRelId relid;
+	LogicalRepTupleData oldtup;
+	LogicalRepTupleData newtup;
+	LogicalRepRelation *rel;
+	List	   *depends_on_xids = NIL;
+	List	   *remote_relids;
+	bool		has_oldtup = false;
+	bool		cascade = false;
+	bool		restart_seqs = false;
+
+	/*
+	 * Parse the consume data using a local copy instead of directly consuming
+	 * the given remote change as the caller may also read the data from the
+	 * remote message.
+	 */
+	StringInfoData change = *s;
+
+	/* Only the leader checks dependencies and schedules the parallel apply */
+	if (!am_leader_apply_worker())
+		return;
+
+	if (!ParallelApplyContext)
+	{
+		Assert(!replica_identity_table);
+
+		/*
+		 * Create a permanent memory context for dependency information that
+		 * persists across transactions. Using a dedicated context makes memory
+		 * consumption more visible and easier to track, especially when
+		 * handling a large number of change entries for transactions being
+		 * applied in parallel.
+		 */
+		ParallelApplyContext = AllocSetContextCreate(ApplyContext,
+													 "ParallelApplyContext",
+													 ALLOCSET_DEFAULT_SIZES);
+
+		replica_identity_table = replica_identity_create(ParallelApplyContext,
+														 REPLICA_IDENTITY_INITIAL_SIZE,
+														 NULL);
+	}
+
+	if (replica_identity_table->members >= REPLICA_IDENTITY_CLEANUP_THRESHOLD)
+		cleanup_committed_replica_identity_entries();
+
+	switch (action)
+	{
+		case LOGICAL_REP_MSG_INSERT:
+			relid = logicalrep_read_insert(&change, &newtup);
+			check_and_record_ri_dependency(relid, &newtup, new_depended_xid,
+										   &depends_on_xids);
+			break;
+
+		case LOGICAL_REP_MSG_UPDATE:
+			relid = logicalrep_read_update(&change, &has_oldtup, &oldtup,
+										   &newtup);
+
+			if (has_oldtup)
+			{
+				check_and_record_ri_dependency(relid, &oldtup, new_depended_xid,
+											   &depends_on_xids);
+
+				/*
+				 * Copy unchanged column values from the old tuple to the new
+				 * tuple for replica identity dependency checking. See
+				 * check_and_record_ri_dependency() for details.
+				 */
+				for (int i = 0; i < oldtup.ncols; i++)
+					if (newtup.colstatus[i] == LOGICALREP_COLUMN_UNCHANGED)
+						newtup.colvalues[i] = oldtup.colvalues[i];
+			}
+
+			check_and_record_ri_dependency(relid, &newtup, new_depended_xid,
+										   &depends_on_xids);
+			break;
+
+		case LOGICAL_REP_MSG_DELETE:
+			relid = logicalrep_read_delete(&change, &oldtup);
+			check_and_record_ri_dependency(relid, &oldtup, new_depended_xid,
+										   &depends_on_xids);
+			break;
+
+		case LOGICAL_REP_MSG_TRUNCATE:
+			remote_relids = logicalrep_read_truncate(&change, &cascade,
+													 &restart_seqs);
+
+			/*
+			 * Truncate affects all rows in a table, so the current
+			 * transaction should wait for all preceding transactions that
+			 * modified the same table.
+			 */
+			foreach_int(truncated_relid, remote_relids)
+				check_and_record_rel_dependency(truncated_relid,
+												new_depended_xid,
+												&depends_on_xids);
+
+			break;
+
+		case LOGICAL_REP_MSG_RELATION:
+			rel = logicalrep_read_rel(&change);
+
+			/*
+			 * The replica identity key could be changed, making existing
+			 * entries in the replica identity invalid. In this case, parallel
+			 * apply is not allowed on this specific table until all running
+			 * transactions that modified it have finished.
+			 */
+			check_and_record_rel_dependency(rel->remoteid, new_depended_xid,
+											&depends_on_xids);
+			break;
+
+		case LOGICAL_REP_MSG_TYPE:
+		case LOGICAL_REP_MSG_MESSAGE:
+
+			/*
+			 * Type updates accompany relation updates, so dependencies have
+			 * already been checked during relation updates. Logical messages
+			 * do not conflict with any changes, so they can be ignored.
+			 */
+			break;
+
+		default:
+			Assert(false);
+			break;
+	}
+
+	/* Return early if the current change has no dependencies. */
+	if (!depends_on_xids)
+		return;
+
+	/*
+	 * If the leader applies the transaction itself, start waiting for
+	 * transactions that the current change depends on to finish. Otherwise,
+	 * instruct the parallel apply worker to wait for them.
+	 */
+	if (winfo == NULL)
+	{
+		Assert(!TransactionIdIsValid(new_depended_xid));
+
+		foreach_xid(xid, depends_on_xids)
+			pa_wait_for_depended_transaction(xid);
+	}
+	else
+	{
+		send_internal_dependencies(winfo, depends_on_xids);
+	}
+}
+
 /*
  * Form the origin name for the subscription.
  *
@@ -792,7 +1451,10 @@ handle_streamed_transaction(LogicalRepMsgType action, StringInfo s)
 
 	/* not in streaming mode */
 	if (apply_action == TRANS_LEADER_APPLY)
+	{
+		handle_dependency_on_change(action, s, InvalidTransactionId, winfo);
 		return false;
+	}
 
 	Assert(TransactionIdIsValid(stream_xid));
 
@@ -1244,6 +1906,29 @@ apply_handle_begin(StringInfo s)
 	pgstat_report_activity(STATE_RUNNING, NULL);
 }
 
+/*
+ * Send an INTERNAL_DEPENDENCY message to a parallel apply worker.
+ */
+static void
+send_internal_dependencies(ParallelApplyWorkerInfo *winfo, List *depends_on_xids)
+{
+	StringInfoData dependencies;
+
+	initStringInfo(&dependencies);
+
+	pq_sendbyte(&dependencies, LOGICAL_REP_MSG_INTERNAL_MESSAGE);
+	pq_sendbyte(&dependencies, PA_MSG_XACT_DEPENDENCY);
+	pq_sendint32(&dependencies, list_length(depends_on_xids));
+
+	foreach_xid(xid, depends_on_xids)
+		pq_sendint32(&dependencies, xid);
+
+	if (pa_send_data(winfo, dependencies.len, dependencies.data))
+		ereport(ERROR,
+				errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+				errmsg("could not send data to the logical replication parallel apply worker"));
+}
+
 /*
  * Handle COMMIT message.
  *
@@ -1771,7 +2456,7 @@ apply_handle_stream_start(StringInfo s)
 
 	/* Try to allocate a worker for the streaming transaction. */
 	if (first_segment)
-		pa_allocate_worker(stream_xid);
+		pa_allocate_worker(stream_xid, true);
 
 	apply_action = get_transaction_apply_action(stream_xid, &winfo);
 
@@ -3980,6 +4665,7 @@ store_flush_position(XLogRecPtr remote_lsn, XLogRecPtr local_lsn)
 	flushpos = palloc_object(FlushPosition);
 	flushpos->local_end = local_lsn;
 	flushpos->remote_end = remote_lsn;
+	flushpos->pa_remote_xid = InvalidTransactionId;
 
 	dlist_push_tail(&lsn_mapping, &flushpos->node);
 	MemoryContextSwitchTo(ApplyMessageContext);
diff --git a/src/include/replication/logicalrelation.h b/src/include/replication/logicalrelation.h
index efe0f9d6031..09890cef8d4 100644
--- a/src/include/replication/logicalrelation.h
+++ b/src/include/replication/logicalrelation.h
@@ -37,6 +37,14 @@ typedef struct LogicalRepRelMapEntry
 	/* Sync state. */
 	char		state;
 	XLogRecPtr	statelsn;
+
+	/*
+	 * The last remote transaction that modified the relation's schema or
+	 * truncated the relation. Used in dependency tracking to ensure subsequent
+	 * transactions modifying the same table wait for this transaction to finish
+	 * applying (see check_dependency_on_rel).
+	 */
+	TransactionId last_depended_xid;
 } LogicalRepRelMapEntry;
 
 extern void logicalrep_relmap_update(LogicalRepRelation *remoterel);
@@ -50,5 +58,6 @@ extern void logicalrep_rel_close(LogicalRepRelMapEntry *rel,
 								 LOCKMODE lockmode);
 extern bool IsIndexUsableForReplicaIdentityFull(Relation idxrel, AttrMap *attrmap);
 extern Oid	GetRelationIdentityOrPK(Relation rel);
+extern LogicalRepRelMapEntry *logicalrep_get_relentry(LogicalRepRelId remoteid);
 
 #endif							/* LOGICALRELATION_H */
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index 43dcae43a62..3c88d9e1027 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -239,6 +239,8 @@ typedef struct ParallelApplyWorkerInfo
 	 */
 	bool		in_use;
 
+	bool		stream_txn;
+
 	ParallelApplyWorkerShared *shared;
 } ParallelApplyWorkerInfo;
 
@@ -350,8 +352,9 @@ extern void apply_error_callback(void *arg);
 extern void set_apply_error_context_origin(char *originname);
 
 /* Parallel apply worker setup and interactions */
-extern void pa_allocate_worker(TransactionId xid);
+extern void pa_allocate_worker(TransactionId xid, bool stream_txn);
 extern ParallelApplyWorkerInfo *pa_find_worker(TransactionId xid);
+extern XLogRecPtr pa_get_last_commit_end(TransactionId xid, bool *skipped_write);
 extern void pa_detach_all_error_mq(void);
 
 extern void apply_handle_internal_message(StringInfo s);
@@ -382,6 +385,7 @@ extern void pa_decr_and_wait_stream_block(void);
 
 extern void pa_xact_finish(ParallelApplyWorkerInfo *winfo,
 						   XLogRecPtr remote_lsn);
+extern bool pa_transaction_committed(TransactionId xid);
 
 extern void pa_wait_for_depended_transaction(TransactionId xid);
 
diff --git a/src/test/subscription/meson.build b/src/test/subscription/meson.build
index e71e95c6297..fdc04d91f41 100644
--- a/src/test/subscription/meson.build
+++ b/src/test/subscription/meson.build
@@ -48,6 +48,7 @@ tests += {
       't/036_sequences.pl',
       't/037_except.pl',
       't/038_walsnd_shutdown_timeout.pl',
+      't/039_row_filter_toast_ri.pl',
       't/100_bugs.pl',
     ],
   },
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index c00c34652fe..07d246bd957 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2669,6 +2669,8 @@ ReplOriginXactState
 ReplaceVarsFromTargetList_context
 ReplaceVarsNoMatchOption
 ReplaceWrapOption
+ReplicaIdentityEntry
+ReplicaIdentityKey
 ReplicaIdentityStmt
 ReplicationKind
 ReplicationSlot
@@ -2680,6 +2682,7 @@ ReplicationSlotPersistentData
 ReplicationState
 ReplicationStateCtl
 ReplicationStateOnDisk
+replica_identity_hash
 ResTarget
 ReservoirState
 ReservoirStateData
-- 
2.43.0



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

* Re: Parallel Apply
  2026-06-04 06:58 RE: Parallel Apply Zhijie Hou (Fujitsu) <[email protected]>
@ 2026-06-09 04:14 ` Peter Smith <[email protected]>
  2026-06-15 11:20   ` RE: Parallel Apply Zhijie Hou (Fujitsu) <[email protected]>
  0 siblings, 1 reply; 63+ messages in thread

From: Peter Smith @ 2026-06-09 04:14 UTC (permalink / raw)
  To: Zhijie Hou (Fujitsu) <[email protected]>; +Cc: shveta malik <[email protected]>; Hayato Kuroda (Fujitsu) <[email protected]>; PostgreSQL Hackers <[email protected]>; Tomas Vondra <[email protected]>; Dilip Kumar <[email protected]>; Andrei Lepikhov <[email protected]>; wenhui qiu <[email protected]>; Amit Kapila <[email protected]>

Hi Hou-San.

Some review comments for v19-0001 and v19-0002

//////////
v19-0001

======
.../replication/logical/applyparallelworker.c

1.
+/* An entry in the parallelized_txns shared hash table */
+typedef struct ParallelizedTxnEntry
+{
+ TransactionId xid; /* Hash key, remote transaction ID */
+} ParallelizedTxnEntry;
+
 /*
  * A hash table used to cache the state of streaming transactions being applied
- * by the parallel apply workers.
+ * by the parallel apply workers. Entries are of type ParallelApplyWorkerEntry.
  */
 static HTAB *ParallelApplyTxnHash = NULL;

+/*
+ * A hash table used to track the parallelized remote transactions
that could be
+ * depended on by other transactions. Entries are of type ParallelizedTxnEntry.
+ *
+ * dshash is used to enable dynamic shared memory allocation based on
the number
+ * of transactions being applied in parallel.
+ */
+static dsa_area *parallel_apply_dsa_area = NULL;
+static dshash_table *parallelized_txns = NULL;
+
+/* parameters for the parallelized_txns shared hash table */
+static const dshash_parameters dsh_params = {
+ sizeof(TransactionId),
+ sizeof(ParallelizedTxnEntry),
+ dshash_memcmp,
+ dshash_memhash,
+ dshash_memcpy,
+ LWTRANCHE_PARALLEL_APPLY_DSA
+};
+

1a.
Maybe that ParallelizedTxnEntry should be moved to just immediately
above 'dshash_parameters' because it seems to belong with that, and
currently it is splitting the ParallelApplyWorkerEntry from the
ParallelApplyTxnHash.

~

1b.
/parameters for/Parameters for/

~~~

pa_attach_parallelized_txn_hash:

2.
+ MemoryContext oldctx;
+
+ if (parallelized_txns)
+ {
+ Assert(parallel_apply_dsa_area);
+ *pa_dsa_handle = dsa_get_handle(parallel_apply_dsa_area);
+ *pa_dshash_handle = dshash_get_hash_table_handle(parallelized_txns);
+ return;
+ }
+
+ /* Be sure any local memory allocated by DSA routines is persistent. */
+ oldctx = MemoryContextSwitchTo(ApplyContext);
+
+ if (am_leader_apply_worker())
+ {
+ /* Initialize dynamic shared hash table for parallelized transactions */
+ parallel_apply_dsa_area = dsa_create(LWTRANCHE_PARALLEL_APPLY_DSA);
+ dsa_pin(parallel_apply_dsa_area);
+ dsa_pin_mapping(parallel_apply_dsa_area);
+ parallelized_txns = dshash_create(parallel_apply_dsa_area, &dsh_params, NULL);
+
+ /* Store handles in shared memory for other backends to use. */
+ *pa_dsa_handle = dsa_get_handle(parallel_apply_dsa_area);
+ *pa_dshash_handle = dshash_get_hash_table_handle(parallelized_txns);
+ }
+ else if (am_parallel_apply_worker())
+ {
+ /* Attach to existing dynamic shared hash table. */
+ parallel_apply_dsa_area =
dsa_attach(MyParallelShared->parallel_apply_dsa_handle);
+ dsa_pin_mapping(parallel_apply_dsa_area);
+ parallelized_txns = dshash_attach(parallel_apply_dsa_area, &dsh_params,
+   MyParallelShared->parallelized_txns_handle,
+   NULL);
+ }
+
+ MemoryContextSwitchTo(oldctx);

2a.
This might be easier to read if rearranged to use if/else instead of
having the early return.

SUGGESTION
if (parallelized_txns)
{
  /* Hashtable is already available */
  ...
}
else
{
  /* Create or attach... */

  MemoryContext oldctx = ...

  if (am_leader_apply_worker())
  {
    /* Create... */
    ...
  }
  else (am_parallel_apply_worker())
  {
    /* Attach... */
    ...
  }

  MemoryContextSwitchTo(oldctx);
}

~~~

2b.
Can it be anything other than
am_leader_apply_worker/am_parallel_apply_worker here? Should there be
an Assert?

~~~

2c.
Since the `dsh_params` are already set up, shouldn't this code be using them?

BEFORE
dsa_create(LWTRANCHE_PARALLEL_APPLY_DSA);

SUGGESTION
dsa_create(dsh_params.tranche_id);


//////////
v19-0002

======
.../replication/logical/applyparallelworker.

1.
+void
+pa_wait_for_depended_transaction(TransactionId xid)
+{
+ ParallelizedTxnEntry *txn_entry;

The declaration of `txn_entry` can be done later within the loop where
it is used.

======
Kind Regards,
Peter Smith.
Fujitsu Australia






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

* RE: Parallel Apply
  2026-06-04 06:58 RE: Parallel Apply Zhijie Hou (Fujitsu) <[email protected]>
  2026-06-09 04:14 ` Re: Parallel Apply Peter Smith <[email protected]>
@ 2026-06-15 11:20   ` Zhijie Hou (Fujitsu) <[email protected]>
  2026-07-09 08:08     ` RE: Parallel Apply Zhijie Hou (Fujitsu) <[email protected]>
  0 siblings, 1 reply; 63+ messages in thread

From: Zhijie Hou (Fujitsu) @ 2026-06-15 11:20 UTC (permalink / raw)
  To: Peter Smith <[email protected]>; +Cc: shveta malik <[email protected]>; Hayato Kuroda (Fujitsu) <[email protected]>; PostgreSQL Hackers <[email protected]>; Tomas Vondra <[email protected]>; Dilip Kumar <[email protected]>; Andrei Lepikhov <[email protected]>; wenhui qiu <[email protected]>; Amit Kapila <[email protected]>

On Tuesday, June 9, 2026 12:14 PM Peter Smith <[email protected]> wrote:
> Hi Hou-San.
> 
> Some review comments for v19-0001 and v19-0002
> 

Thanks for the comments.

> //////////
> v19-0001
> 
> 1a.
> Maybe that ParallelizedTxnEntry should be moved to just immediately above
> 'dshash_parameters' because it seems to belong with that, and currently it is
> splitting the ParallelApplyWorkerEntry from the ParallelApplyTxnHash.

Moved as suggested.

> 
> ~
> 
> 1b.
> /parameters for/Parameters for/
> 
> ~~~

Fixed.

> pa_attach_parallelized_txn_hash:
> 2a.
> This might be easier to read if rearranged to use if/else instead of having the
> early return.

I'm not in favor of this style, as deeply nested condition blocks are harder for
me to understand. Please see existing examples like init_dsm_registry,
initGlobalChannelTable, and logicalrep_launcher_attach_dshmem for the preferred
coding style.


> 
> ~~~
> 
> 2b.
> Can it be anything other than
> am_leader_apply_worker/am_parallel_apply_worker here? Should there be
> an Assert?

Only the leader and parallel apply workers need to coordinate in this context,
so I didn't add an Assert. Even if other processes use it, it will be a no-op,
which is fine.


> 
> ~~~
> 
> 2c.
> Since the `dsh_params` are already set up, shouldn't this code be using them?
> 
> BEFORE
> dsa_create(LWTRANCHE_PARALLEL_APPLY_DSA);
> 
> SUGGESTION
> dsa_create(dsh_params.tranche_id);
> 

I think dsa_create and dsh_params are two different objects. The former is for
pure shared memory allocation, while the latter is part of a hash table.
Therefore, it's not necessary to pass dsh_params to dsa_create. We could even
use different LWTRANCHE for them if we wanted, but we don't have a special need
to create another one at the moment.


> 
> //////////
> v19-0002
> 
> ======
> .../replication/logical/applyparallelworker.
> 
> 1.
> +void
> +pa_wait_for_depended_transaction(TransactionId xid) {
> +ParallelizedTxnEntry *txn_entry;
> 
> The declaration of `txn_entry` can be done later within the loop where it is
> used.

Moved.

Attaching the new version patch set.

Apart from addressing above comments, I also did the following changes:

0003:

- Fix detection of dependencies on toasted columns by properly copying column
  status.
- Ensure leader correctly handles table-wide dependencies when applying a
  transaction directly.
- Prevent infinite loop by skipping self-dependencies when building the
  dependency list.
- Expand test coverage for dependency tracking.
- Improve code readability through refactoring.
- Enhance comments for better documentation.

0008:
0009:

- Complete most TODOs and finalize the design to cover more cases. Add extensive
  comments to explain the design.

- Add more tests to cover the new code paths.

Best Regards,
Hou zj


Attachments:

  [application/octet-stream] v20-0001-Introduce-a-shared-hash-table-to-store-paralleli.patch (9.1K, ../../TY4PR01MB1771875A7A64B139EF8C8D05C94E62@TY4PR01MB17718.jpnprd01.prod.outlook.com/2-v20-0001-Introduce-a-shared-hash-table-to-store-paralleli.patch)
  download | inline diff:
From 723bbdf3202ccbce880c9c3c83ea085eeba0732d Mon Sep 17 00:00:00 2001
From: Zhijie Hou <[email protected]>
Date: Wed, 27 May 2026 11:31:35 +0800
Subject: [PATCH v20 1/4] Introduce a shared hash table to store parallelized
 transactions

This commit introduces a shared hash table for tracking parallelized
transactions. The hash table uses transaction IDs as keys; no values are stored.

The hash entries are inserted with a remote XID when the leader bypasses remote
transactions to parallel apply workers. Entries are deleted when parallel
workers are committed to corresponding transactions.

Any worker can use this hash table to check whether a transaction is still
in progress:
- If an entry exists, the transaction is still in progress.
- If no entry exists, the transaction has committed.

Later patches will use this hash table to support dependency waiting and
commit order preservation. When transaction A depends on transaction B
(or must commit after B), the worker will wait for transaction B's entry
to be removed before committing transaction A.

Author: Zhijie Hou <[email protected]>
Author: Hayato Kuroda <[email protected]>
---
 .../replication/logical/applyparallelworker.c | 90 ++++++++++++++++++-
 .../utils/activity/wait_event_names.txt       |  1 +
 src/include/replication/worker_internal.h     |  9 ++
 src/include/storage/lwlocklist.h              |  1 +
 src/tools/pgindent/typedefs.list              |  1 +
 5 files changed, 101 insertions(+), 1 deletion(-)

diff --git a/src/backend/replication/logical/applyparallelworker.c b/src/backend/replication/logical/applyparallelworker.c
index 012d55e9d3d..5eb0679e850 100644
--- a/src/backend/replication/logical/applyparallelworker.c
+++ b/src/backend/replication/logical/applyparallelworker.c
@@ -223,10 +223,36 @@ typedef struct ParallelApplyWorkerEntry
 
 /*
  * A hash table used to cache the state of streaming transactions being applied
- * by the parallel apply workers.
+ * by the parallel apply workers. Entries are of type ParallelApplyWorkerEntry.
  */
 static HTAB *ParallelApplyTxnHash = NULL;
 
+/* An entry in the parallelized_txns shared hash table */
+typedef struct ParallelizedTxnEntry
+{
+	TransactionId xid;			/* Hash key, remote transaction ID */
+} ParallelizedTxnEntry;
+
+/*
+ * A hash table used to track the parallelized remote transactions that could be
+ * depended on by other transactions. Entries are of type ParallelizedTxnEntry.
+ *
+ * dshash is used to enable dynamic shared memory allocation based on the number
+ * of transactions being applied in parallel.
+ */
+static dsa_area *parallel_apply_dsa_area = NULL;
+static dshash_table *parallelized_txns = NULL;
+
+/* Parameters for the parallelized_txns shared hash table */
+static const dshash_parameters dsh_params = {
+	sizeof(TransactionId),
+	sizeof(ParallelizedTxnEntry),
+	dshash_memcmp,
+	dshash_memhash,
+	dshash_memcpy,
+	LWTRANCHE_PARALLEL_APPLY_DSA
+};
+
 /*
  * A list (pool) of active parallel apply workers. The information for
  * the new worker is added to the list after successfully launching it. The
@@ -260,6 +286,8 @@ static List *subxactlist = NIL;
 static void pa_free_worker_info(ParallelApplyWorkerInfo *winfo);
 static ParallelTransState pa_get_xact_state(ParallelApplyWorkerShared *wshared);
 static PartialFileSetState pa_get_fileset_state(void);
+static void pa_attach_parallelized_txn_hash(dsa_handle *pa_dsa_handle,
+											dshash_table_handle *pa_dshash_handle);
 
 /*
  * Returns true if it is OK to start a parallel apply worker, false otherwise.
@@ -337,6 +365,15 @@ pa_setup_dsm(ParallelApplyWorkerInfo *winfo)
 	shm_mq	   *mq;
 	Size		queue_size = DSM_QUEUE_SIZE;
 	Size		error_queue_size = DSM_ERROR_QUEUE_SIZE;
+	dsa_handle	parallel_apply_dsa_handle;
+	dshash_table_handle parallelized_txns_handle;
+
+	pa_attach_parallelized_txn_hash(&parallel_apply_dsa_handle,
+									&parallelized_txns_handle);
+
+	if (parallel_apply_dsa_handle == DSA_HANDLE_INVALID ||
+		parallelized_txns_handle == DSHASH_HANDLE_INVALID)
+		return false;
 
 	/*
 	 * Estimate how much shared memory we need.
@@ -372,6 +409,8 @@ pa_setup_dsm(ParallelApplyWorkerInfo *winfo)
 	pg_atomic_init_u32(&(shared->pending_stream_count), 0);
 	shared->last_commit_end = InvalidXLogRecPtr;
 	shared->fileset_state = FS_EMPTY;
+	shared->parallel_apply_dsa_handle = parallel_apply_dsa_handle;
+	shared->parallelized_txns_handle = parallelized_txns_handle;
 
 	shm_toc_insert(toc, PARALLEL_APPLY_KEY_SHARED, shared);
 
@@ -876,6 +915,8 @@ ParallelApplyWorkerMain(Datum main_arg)
 	shm_mq	   *mq;
 	shm_mq_handle *mqh;
 	shm_mq_handle *error_mqh;
+	dsa_handle	pa_dsa_handle;
+	dshash_table_handle pa_dshash_handle;
 	ReplOriginId originid;
 	int			worker_slot = DatumGetInt32(main_arg);
 	char		originname[NAMEDATALEN];
@@ -962,6 +1003,8 @@ ParallelApplyWorkerMain(Datum main_arg)
 
 	InitializingApplyWorker = false;
 
+	pa_attach_parallelized_txn_hash(&pa_dsa_handle, &pa_dshash_handle);
+
 	/* Setup replication origin tracking. */
 	StartTransactionCommand();
 	ReplicationOriginNameForLogicalRep(MySubscription->oid, InvalidOid,
@@ -1656,3 +1699,48 @@ pa_xact_finish(ParallelApplyWorkerInfo *winfo, XLogRecPtr remote_lsn)
 
 	pa_free_worker(winfo);
 }
+
+/*
+ * Attach to the shared hash table for parallelized transactions.
+ */
+static void
+pa_attach_parallelized_txn_hash(dsa_handle *pa_dsa_handle,
+								dshash_table_handle *pa_dshash_handle)
+{
+	MemoryContext oldctx;
+
+	if (parallelized_txns)
+	{
+		Assert(parallel_apply_dsa_area);
+		*pa_dsa_handle = dsa_get_handle(parallel_apply_dsa_area);
+		*pa_dshash_handle = dshash_get_hash_table_handle(parallelized_txns);
+		return;
+	}
+
+	/* Be sure any local memory allocated by DSA routines is persistent. */
+	oldctx = MemoryContextSwitchTo(ApplyContext);
+
+	if (am_leader_apply_worker())
+	{
+		/* Initialize dynamic shared hash table for parallelized transactions */
+		parallel_apply_dsa_area = dsa_create(LWTRANCHE_PARALLEL_APPLY_DSA);
+		dsa_pin(parallel_apply_dsa_area);
+		dsa_pin_mapping(parallel_apply_dsa_area);
+		parallelized_txns = dshash_create(parallel_apply_dsa_area, &dsh_params, NULL);
+
+		/* Store handles in shared memory for other backends to use. */
+		*pa_dsa_handle = dsa_get_handle(parallel_apply_dsa_area);
+		*pa_dshash_handle = dshash_get_hash_table_handle(parallelized_txns);
+	}
+	else if (am_parallel_apply_worker())
+	{
+		/* Attach to existing dynamic shared hash table. */
+		parallel_apply_dsa_area = dsa_attach(MyParallelShared->parallel_apply_dsa_handle);
+		dsa_pin_mapping(parallel_apply_dsa_area);
+		parallelized_txns = dshash_attach(parallel_apply_dsa_area, &dsh_params,
+										  MyParallelShared->parallelized_txns_handle,
+										  NULL);
+	}
+
+	MemoryContextSwitchTo(oldctx);
+}
diff --git a/src/backend/utils/activity/wait_event_names.txt b/src/backend/utils/activity/wait_event_names.txt
index 560659f9568..1339c33262f 100644
--- a/src/backend/utils/activity/wait_event_names.txt
+++ b/src/backend/utils/activity/wait_event_names.txt
@@ -417,6 +417,7 @@ XactSLRU	"Waiting to access the transaction status SLRU cache."
 ParallelVacuumDSA	"Waiting for parallel vacuum dynamic shared memory allocation."
 AioUringCompletion	"Waiting for another process to complete IO via io_uring."
 ShmemIndex	"Waiting to find or allocate space in shared memory."
+ParallelApplyDSA	"Waiting for parallel apply dynamic shared memory allocation."
 
 # No "ABI_compatibility" region here as WaitEventLWLock has its own C code.
 
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index 745b7d9e969..e60e0c3d6d7 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -15,6 +15,7 @@
 #include "access/xlogdefs.h"
 #include "catalog/pg_subscription.h"
 #include "datatype/timestamp.h"
+#include "lib/dshash.h"
 #include "miscadmin.h"
 #include "replication/logicalrelation.h"
 #include "replication/walreceiver.h"
@@ -196,6 +197,14 @@ typedef struct ParallelApplyWorkerShared
 	 */
 	PartialFileSetState fileset_state;
 	FileSet		fileset;
+
+	/*
+	 * DSA handle for parallel apply workers, along with the handle for the
+	 * shared hash table allocated under it. The hash table stores parallelized
+	 * transaction information.
+	 */
+	dsa_handle	parallel_apply_dsa_handle;
+	dshash_table_handle parallelized_txns_handle;
 } ParallelApplyWorkerShared;
 
 /*
diff --git a/src/include/storage/lwlocklist.h b/src/include/storage/lwlocklist.h
index d7eb648bd27..c5f2921b703 100644
--- a/src/include/storage/lwlocklist.h
+++ b/src/include/storage/lwlocklist.h
@@ -140,3 +140,4 @@ PG_LWLOCKTRANCHE(XACT_SLRU, XactSLRU)
 PG_LWLOCKTRANCHE(PARALLEL_VACUUM_DSA, ParallelVacuumDSA)
 PG_LWLOCKTRANCHE(AIO_URING_COMPLETION, AioUringCompletion)
 PG_LWLOCKTRANCHE(SHMEM_INDEX, ShmemIndex)
+PG_LWLOCKTRANCHE(PARALLEL_APPLY_DSA, ParallelApplyDSA)
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 8cf40c87043..fabda17d739 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2162,6 +2162,7 @@ ParallelHashJoinBatch
 ParallelHashJoinBatchAccessor
 ParallelHashJoinState
 ParallelIndexScanDesc
+ParallelizedTxnEntry
 ParallelSlot
 ParallelSlotArray
 ParallelSlotResultHandler
-- 
2.43.0



  [application/octet-stream] v20-0009-Support-dependency-tracking-via-local-foreign-ke.patch (42.4K, ../../TY4PR01MB1771875A7A64B139EF8C8D05C94E62@TY4PR01MB17718.jpnprd01.prod.outlook.com/3-v20-0009-Support-dependency-tracking-via-local-foreign-ke.patch)
  download | inline diff:
From 90be0e5b2943aa4074a2274db45e6c7230733f0a Mon Sep 17 00:00:00 2001
From: Zhijie Hou <[email protected]>
Date: Sat, 13 Jun 2026 19:23:28 +0800
Subject: [PATCH v20 9/9] Support dependency tracking via local foreign keys

This patch tracks foreign key dependencies to prevent constraint violations.
Consider a referencing table FK_TABLE (a INT REFERENCES PK_TABLE) and a
referenced table PK_TABLE (a INT PRIMARY KEY):

  TX-1: INSERT row (1) INTO PK_TABLE
  TX-2: INSERT row (1) INTO FK_TABLE

TX-2 must wait for TX-1; otherwise, a foreign key violation occurs.

Similarly, for deletions:

  TX-1: DELETE row (1) FROM FK_TABLE
  TX-2: DELETE row (1) FROM PK_TABLE

TX-2 must wait for TX-1 to avoid violating the foreign key constraint.

Therefore, we record new tuples on the referenced table (PK_TABLE) and check
for dependencies when processing new tuples on the referencing table
(FK_TABLE). Conversely, we record old tuples deleted from the referencing
table and require deletions on the referenced table to depend on them.

Similar to unique key dependencies, for delete-delete cases we only track
foreign key dependencies on columns that are part of the replica identity
keys on the publisher. This may lead to false positives but is acceptable
given the trade-off.
---
 .../replication/logical/applyparallelworker.c |  26 +
 src/backend/replication/logical/relation.c    | 496 ++++++++++++++++++
 src/backend/replication/logical/worker.c      | 300 ++++++++++-
 src/include/replication/logicalrelation.h     |  22 +
 src/test/subscription/t/050_parallel_apply.pl | 164 ++++++
 5 files changed, 998 insertions(+), 10 deletions(-)

diff --git a/src/backend/replication/logical/applyparallelworker.c b/src/backend/replication/logical/applyparallelworker.c
index 8826faf102c..32144bb57fa 100644
--- a/src/backend/replication/logical/applyparallelworker.c
+++ b/src/backend/replication/logical/applyparallelworker.c
@@ -218,6 +218,32 @@
  * replica identity keys. This could be useful for users who prefer higher
  * parallelism and experience few conflicts.
  *
+ * Additionally, foreign key dependencies are also tracked to prevent constraint
+ * violations. Consider a referencing table FK_TABLE (a INT REFERENCES PK_TABLE)
+ * and a referenced table PK_TABLE (a INT PRIMARY KEY):
+ *
+ *   TX-1: INSERT row (1) INTO PK_TABLE
+ *   TX-2: INSERT row (1) INTO FK_TABLE
+ *
+ * TX-2 must wait for TX-1; otherwise, a foreign key violation occurs.
+ *
+ * Similarly, for deletions:
+ *
+ *   TX-1: DELETE row (1) FROM FK_TABLE
+ *   TX-2: DELETE row (1) FROM PK_TABLE
+ *
+ * TX-2 must wait for TX-1 to avoid violating the foreign key constraint.
+ *
+ * Therefore, we record new tuples on the referenced table (PK_TABLE) and check
+ * for dependencies when processing new tuples on the referencing table
+ * (FK_TABLE). Conversely, we record old tuples deleted from the referencing
+ * table and require deletions on the referenced table to depend on them.
+ *
+ * Similar to unique key dependencies, for delete-delete cases we only track
+ * foreign key dependencies on columns that are part of the replica identity
+ * keys on the publisher. This may lead to false positives but is acceptable
+ * given the trade-off.
+ *
  * Note that the local unique key could change after dependency checking and
  * before applying the change. However, to centralize tracking and keep it
  * simple, we still perform this check only in the leader apply worker. This is
diff --git a/src/backend/replication/logical/relation.c b/src/backend/replication/logical/relation.c
index 8eef87ca910..f598ffe7117 100644
--- a/src/backend/replication/logical/relation.c
+++ b/src/backend/replication/logical/relation.c
@@ -20,7 +20,9 @@
 #include "access/amapi.h"
 #include "access/genam.h"
 #include "access/table.h"
+#include "catalog/heap.h"
 #include "catalog/namespace.h"
+#include "catalog/pg_constraint.h"
 #include "catalog/pg_proc.h"
 #include "catalog/pg_subscription_rel.h"
 #include "commands/trigger.h"
@@ -28,6 +30,7 @@
 #include "nodes/makefuncs.h"
 #include "replication/logicalrelation.h"
 #include "replication/worker_internal.h"
+#include "utils/fmgroids.h"
 #include "utils/inval.h"
 #include "utils/lsyscache.h"
 #include "utils/syscache.h"
@@ -85,6 +88,7 @@ logicalrep_relmap_invalidate_cb(Datum arg, Oid reloid)
 			{
 				entry->localrelvalid = false;
 				entry->local_unique_indexes_collected = false;
+				entry->local_fkeys_collected = false;
 				hash_seq_term(&status);
 				break;
 			}
@@ -101,6 +105,7 @@ logicalrep_relmap_invalidate_cb(Datum arg, Oid reloid)
 		{
 			entry->localrelvalid = false;
 			entry->local_unique_indexes_collected = false;
+			entry->local_fkeys_collected = false;
 		}
 	}
 }
@@ -147,6 +152,33 @@ free_local_unique_indexes(LogicalRepRelMapEntry *entry)
 	entry->local_unique_indexes = NIL;
 }
 
+/*
+ * Release local foreign key lists.
+ */
+static void
+free_local_fkeys(LogicalRepRelMapEntry *entry)
+{
+	Assert(am_leader_apply_worker());
+
+	foreach_ptr(LogicalRepSubscriberFK, fkinfo, entry->local_fkeys)
+	{
+		list_free(fkinfo->fkattnums);
+		list_free(fkinfo->fkattnums_old);
+	}
+
+	foreach_ptr(LogicalRepSubscriberRefFK, refinfo, entry->local_referenced_fkeys)
+	{
+		list_free(refinfo->refattnums);
+		list_free(refinfo->refattnums_old);
+	}
+
+	list_free_deep(entry->local_fkeys);
+	list_free_deep(entry->local_referenced_fkeys);
+
+	entry->local_fkeys = NIL;
+	entry->local_referenced_fkeys = NIL;
+}
+
 /*
  * Free the entry of a relation map cache.
  */
@@ -177,6 +209,9 @@ logicalrep_relmap_free_entry(LogicalRepRelMapEntry *entry)
 
 	if (entry->local_unique_indexes != NIL)
 		free_local_unique_indexes(entry);
+
+	if (entry->local_fkeys != NIL || entry->local_referenced_fkeys != NIL)
+		free_local_fkeys(entry);
 }
 
 /*
@@ -240,6 +275,7 @@ logicalrep_relmap_update(LogicalRepRelation *remoterel)
 
 	entry->parallel_safe = LOGICALREP_PARALLEL_UNKNOWN;
 	entry->local_unique_indexes_collected = false;
+	entry->local_fkeys_collected = false;
 	MemoryContextSwitchTo(oldctx);
 }
 
@@ -532,6 +568,461 @@ collect_indexes_for_dependency_tracking(LogicalRepRelMapEntry *entry)
 	entry->local_unique_indexes_collected = true;
 }
 
+/*
+ * Search a relmap entry by local relation OID.
+ */
+static LogicalRepRelMapEntry *
+logicalrep_get_relentry_by_local_oid(Oid localreloid)
+{
+	HASH_SEQ_STATUS status;
+	LogicalRepRelMapEntry *entry = NULL;
+
+	if (LogicalRepRelMap == NULL)
+		return NULL;
+
+	hash_seq_init(&status, LogicalRepRelMap);
+	while ((entry = (LogicalRepRelMapEntry *) hash_seq_search(&status)) != NULL)
+	{
+		if (entry->localreloid == localreloid)
+		{
+			hash_seq_term(&status);
+			return entry;
+		}
+	}
+
+	return NULL;
+}
+
+/*
+ * Return remote relation IDs that have FK dependency metadata tied to the
+ * specified relation, either as referencing-side or referenced-side entries.
+ */
+List *
+logicalrep_get_fk_related_relids(LogicalRepRelation *remoterel)
+{
+	LogicalRepRelMapEntry *target;
+	bool		found;
+	List	   *related = NIL;
+	Oid		relnamespace;
+	Oid			remoteid = remoterel->remoteid;
+
+	if (LogicalRepRelMap == NULL)
+		return NIL;
+
+	target = hash_search(LogicalRepRelMap, &remoteid, HASH_FIND, &found);
+
+	/*
+	 * If not exists yet, build a new entry so that we can collect all the
+	 * referenced and referencing tables.
+	 */
+	if (!found)
+	{
+		logicalrep_relmap_update(remoterel);
+		target = hash_search(LogicalRepRelMap, &remoteid, HASH_FIND, &found);
+	}
+
+	Assert(found);
+
+	/* Collect the tables if not yet */
+	if (!target->local_fkeys_collected)
+	{
+		bool		needs_start = !IsTransactionOrTransactionBlock();
+
+		if (needs_start)
+			StartTransactionCommand();
+
+		/* Return if the relation does not exist */
+		relnamespace = get_namespace_oid(remoterel->nspname, true);
+		if (!OidIsValid(get_relname_relid(remoterel->relname, relnamespace)))
+		{
+			if (needs_start)
+				CommitTransactionCommand();
+
+			return NIL;
+		}
+
+		logicalrep_rel_load(NULL, remoteid, AccessShareLock);
+
+		if (needs_start)
+			CommitTransactionCommand();
+	}
+
+	Assert(target->local_fkeys_collected);
+
+	/* Collect all the tables referenced by the given table */
+	foreach_ptr(LogicalRepSubscriberFK, fkinfo, target->local_fkeys)
+		related = list_append_unique_oid(related, fkinfo->ref_remoteid);
+
+	/* Collect all the tables referencing the given table */
+	foreach_ptr(LogicalRepSubscriberRefFK, refinfo, target->local_referenced_fkeys)
+		related = list_append_unique_oid(related, refinfo->fk_remoteid);
+
+	/* Remove the given table itself from the list */
+	related = list_delete_oid(related, remoteid);
+
+	return related;
+}
+
+/*
+ * Return true if the FK constraint is always deferred.
+ */
+static bool
+foreign_key_is_always_deferred(Oid conoid)
+{
+	HeapTuple	tup;
+	Form_pg_constraint con;
+	bool		deferred;
+
+	tup = SearchSysCache1(CONSTROID, ObjectIdGetDatum(conoid));
+	if (!HeapTupleIsValid(tup))
+		elog(ERROR, "cache lookup failed for foreign key %u", conoid);
+
+	con = (Form_pg_constraint) GETSTRUCT(tup);
+	deferred = con->condeferrable && con->condeferred;
+	ReleaseSysCache(tup);
+
+	return deferred;
+}
+
+/*
+ * Check whether the table has any referencing-side foreign key triggers (ON
+ * INSERT or ON UPDATE) enabled in replica mode that could cause a foreign key
+ * violation error.
+ */
+static bool
+fkey_trigger_enabled_in_replica(Relation rel, Oid conoid)
+{
+	TriggerDesc *trigdesc = rel->trigdesc;
+
+	if (trigdesc == NULL)
+		return false;
+
+	for (int i = 0; i < trigdesc->numtriggers; i++)
+	{
+		Trigger    *trig = &trigdesc->triggers[i];
+
+		/* Keep only FK-side check triggers that belong to this FK constraint */
+		if (trig->tgconstraint != conoid ||
+			(trig->tgfoid != F_RI_FKEY_CHECK_INS &&
+			 trig->tgfoid != F_RI_FKEY_CHECK_UPD))
+			continue;
+
+		/* In replica mode, only REPLICA/ALWAYS triggers can fire */
+		if (trig->tgenabled == TRIGGER_FIRES_ON_REPLICA ||
+			trig->tgenabled == TRIGGER_FIRES_ALWAYS)
+			return true;
+	}
+
+	return false;
+}
+
+/*
+ * Check whether the table has any referenced-side foreign key triggers (ON
+ * DELETE or ON UPDATE) enabled in replica mode that could cause a foreign key
+ * violation error.
+ */
+static bool
+refkey_trigger_enabled_in_replica(Relation rel, Oid conoid)
+{
+	TriggerDesc *trigdesc = rel->trigdesc;
+
+	if (trigdesc == NULL)
+		return false;
+
+	for (int i = 0; i < trigdesc->numtriggers; i++)
+	{
+		Trigger    *trig = &trigdesc->triggers[i];
+
+		/* Keep only PK-side action triggers for this FK constraint */
+		if (trig->tgconstraint != conoid ||
+			(trig->tgfoid != F_RI_FKEY_NOACTION_DEL &&
+			 trig->tgfoid != F_RI_FKEY_NOACTION_UPD &&
+			 trig->tgfoid != F_RI_FKEY_RESTRICT_DEL &&
+			 trig->tgfoid != F_RI_FKEY_RESTRICT_UPD))
+			continue;
+
+		/* In replica mode, only REPLICA/ALWAYS triggers can fire. */
+		if (trig->tgenabled == TRIGGER_FIRES_ON_REPLICA ||
+			trig->tgenabled == TRIGGER_FIRES_ALWAYS)
+			return true;
+	}
+
+	return false;
+}
+
+/*
+ * Build a list of foreign key remote columns in the order of the referenced
+ * table's remote columns.
+ */
+static void
+build_fk_remote_attnums(LogicalRepRelMapEntry *fkentry,
+						LogicalRepRelMapEntry *refentry,
+						int nkeys,
+						const AttrNumber *fk_conkey,
+						const AttrNumber *ref_confkey,
+						List **fkattnums,
+						List **fkattnums_old,
+						List **refattnums,
+						List **refattnums_old)
+{
+	int			pkatt = -1;
+	List	   *fkcols = NIL;
+	List	   *fkcols_old = NIL;
+	List	   *refcols = NIL;
+	List	   *refcols_old = NIL;
+
+	/*
+	 * Traverse the referenced table's remote replica identity columns in order,
+	 * and for each, find the corresponding foreign key column that matches it.
+	 */
+	while ((pkatt = bms_next_member(refentry->remoterel.attkeys, pkatt)) >= 0)
+	{
+		for (int i = 0; i < nkeys; i++)
+		{
+			AttrNumber	fk_local_attnum = fk_conkey[i];
+			AttrNumber	ref_local_attnum = ref_confkey[i];
+			int			fk_remote_attnum;
+			int			ref_remote_attnum;
+
+			fk_remote_attnum = fkentry->attrmap->attnums[AttrNumberGetAttrOffset(fk_local_attnum)];
+			ref_remote_attnum = refentry->attrmap->attnums[AttrNumberGetAttrOffset(ref_local_attnum)];
+
+			/* Skip if not the current traversed column */
+			if (ref_remote_attnum != pkatt)
+				continue;
+
+			/* Skip columns that are unavailable in the remote table */
+			if (ref_remote_attnum < 0 || fk_remote_attnum < 0)
+				continue;
+
+			fkcols = lappend_int(fkcols, fk_remote_attnum + 1);
+			refcols = lappend_int(refcols, ref_remote_attnum + 1);
+
+			/* Old tuple contains only replica identity columns. */
+			if (bms_is_member(fk_remote_attnum, fkentry->remoterel.attkeys) &&
+				bms_is_member(ref_remote_attnum, refentry->remoterel.attkeys))
+			{
+				fkcols_old = lappend_int(fkcols_old, fk_remote_attnum + 1);
+				refcols_old = lappend_int(refcols_old, ref_remote_attnum + 1);
+			}
+
+			break;
+		}
+	}
+
+	*fkattnums = fkcols;
+	*fkattnums_old = fkcols_old;
+	*refattnums = refcols;
+	*refattnums_old = refcols_old;
+}
+
+/*
+ * Collect referenced-side key projections for dependency tracking
+ *
+ * Helper for collect_fkeys_for_dependency_tracking(). See that function for
+ * detailed comments.
+ */
+static void
+collect_refkeys_for_dependency_tracking(LogicalRepRelMapEntry *entry)
+{
+	Relation	fkeyRel;
+	SysScanDesc fkeyScan;
+	HeapTuple	tuple;
+	Oid			relid = RelationGetRelid(entry->localrel);
+
+	Assert(OidIsValid(relid));
+
+	fkeyRel = table_open(ConstraintRelationId, AccessShareLock);
+
+	fkeyScan = systable_beginscan(fkeyRel, InvalidOid, false,
+								  NULL, 0, NULL);
+
+	while (HeapTupleIsValid(tuple = systable_getnext(fkeyScan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(tuple);
+		LogicalRepRelMapEntry *fkentry;
+		LogicalRepSubscriberRefFK *refinfo;
+		List	   *fkattnums;
+		List	   *fkattnums_old;
+		List	   *refattnums;
+		List	   *refattnums_old;
+		AttrNumber	conkey[INDEX_MAX_KEYS] = {0};
+		AttrNumber	confkey[INDEX_MAX_KEYS] = {0};
+		int			numfks;
+		MemoryContext oldctx;
+
+		/* Not a foreign key */
+		if (con->contype != CONSTRAINT_FOREIGN)
+			continue;
+
+		/* Not referencing the given table */
+		if (con->confrelid != relid)
+			continue;
+
+		/* Skip if FK enforcement is disabled */
+		if (!con->conenforced)
+			continue;
+
+		/* Always-deferred FK does not need dependency tracking. */
+		if (foreign_key_is_always_deferred(con->oid))
+			continue;
+
+		/*
+		 * Skip when no replica-mode ON DELETE/ON UPDATE violation trigger can
+		 * fire for this FK on the referenced table.
+		 */
+		if (!refkey_trigger_enabled_in_replica(entry->localrel, con->oid))
+			continue;
+
+		fkentry = logicalrep_get_relentry_by_local_oid(con->conrelid);
+
+		/*
+		 * Skip if the referencing table is not published or has not replicated
+		 * any changes.
+		 */
+		if (!fkentry || !fkentry->attrmap)
+			continue;
+
+		DeconstructFkConstraintRow(tuple, &numfks, conkey, confkey,
+								   NULL, NULL, NULL, NULL, NULL);
+
+		build_fk_remote_attnums(fkentry, entry, numfks, conkey, confkey,
+								&fkattnums, &fkattnums_old,
+								&refattnums, &refattnums_old);
+
+		list_free(fkattnums);
+		list_free(fkattnums_old);
+
+		oldctx = MemoryContextSwitchTo(LogicalRepRelMapContext);
+		refinfo = palloc_object(LogicalRepSubscriberRefFK);
+		refinfo->conoid = con->oid;
+		refinfo->fk_remoteid = fkentry->remoterel.remoteid;
+		refinfo->refattnums = list_copy(refattnums);
+		refinfo->refattnums_old = list_copy(refattnums_old);
+		entry->local_referenced_fkeys = lappend(entry->local_referenced_fkeys,
+												refinfo);
+		MemoryContextSwitchTo(oldctx);
+
+		list_free(refattnums);
+		list_free(refattnums_old);
+	}
+
+	systable_endscan(fkeyScan);
+
+	table_close(fkeyRel, AccessShareLock);
+}
+
+/*
+ * Collect foreign keys for dependency tracking.
+ *
+ * This function collects both foreign keys in the referencing table and the
+ * primary key in the referenced table, as both are needed for dependency
+ * tracking (see applyparallelworker.c for details).
+ *
+ * For a primary key, we directly record the bitmap of remote columns that the
+ * foreign key references.
+ *
+ * For a foreign key in the referencing table, we cannot use the columns as-is
+ * because their order on the remote side may differ from the local referencing
+ * and referenced table. To ensure consistency, we store a list of column
+ * numbers in the order of the referenced table's remote columns. The dependency
+ * tracking function will traverse this list to build the hash key.
+ *
+ * For one foreign key constraint on the table, We collect two set of column
+ * numbers for both the referencing and referenced tables: one for the new tuple
+ * of an INSERT or UPDATE, and the other for the old tuple of an UPDATE or
+ * DELETE. The former includes all remote columns that the foreign key
+ * references, while the latter includes only those that are part of the replica
+ * identity key. This is because the old tuple of an UPDATE or DELETE contains
+ * only replica identity key columns, and any other columns would be missing and
+ * thus unavailable for dependency tracking.
+ *
+ * If there are multiple foreign keys referencing other tables or if the primary
+ * key is referenced by multiple foreign keys, we will have multiple sets of
+ * column numbers.
+ *
+ * When recording or checking a foreign key dependency, we fill columns in the
+ * referencing table that are outside the replica identity as NULL, and during
+ * comparison, we treat NULL as equal to any value. This is safe because the
+ * referenced key is a primary key (no NULLs allowed), and NULL values in the
+ * referencing key never participate in foreign key constraint checks.
+ * Therefore, we will not encounter genuine NULL values in the remote columns.
+ */
+static void
+collect_fkeys_for_dependency_tracking(LogicalRepRelMapEntry *entry)
+{
+	List	   *fkeys;
+	MemoryContext oldctx;
+
+	if (entry->local_fkeys != NIL || entry->local_referenced_fkeys != NIL)
+		free_local_fkeys(entry);
+
+	fkeys = copyObject(RelationGetFKeyList(entry->localrel));
+
+	/* Collect foreign keys where this table is the referencing side */
+	foreach_ptr(ForeignKeyCacheInfo, fk, fkeys)
+	{
+		LogicalRepRelMapEntry *refentry;
+		List	  *fkattnums;
+		List	  *fkattnums_old;
+		List	  *refattnums;
+		List	  *refattnums_old;
+		LogicalRepSubscriberFK *fkinfo;
+
+		/* Skip if FK enforcement is disabled */
+		if (!fk->conenforced)
+			continue;
+
+		/*
+		 * Skip if this FK's check trigger is disabled in replica mode.
+		 */
+		if (!fkey_trigger_enabled_in_replica(entry->localrel, fk->conoid))
+			continue;
+
+		/*
+		 * Deferrable foreign keys do not need tracking, as they won't cause
+		 * constraint violations as long as commit order is preserved.
+		 */
+		if (foreign_key_is_always_deferred(fk->conoid))
+			continue;
+
+		refentry = logicalrep_get_relentry_by_local_oid(fk->confrelid);
+
+		/*
+		 * Skip if the referenced table is not published or has not replicated
+		 * any changes.
+		 */
+		if (!refentry || !refentry->attrmap)
+			continue;
+
+		build_fk_remote_attnums(entry, refentry, fk->nkeys, fk->conkey,
+								fk->confkey, &fkattnums, &fkattnums_old,
+								&refattnums, &refattnums_old);
+
+		list_free(refattnums);
+		list_free(refattnums_old);
+
+		oldctx = MemoryContextSwitchTo(LogicalRepRelMapContext);
+		fkinfo = palloc_object(LogicalRepSubscriberFK);
+		fkinfo->conoid = fk->conoid;
+		fkinfo->ref_remoteid = refentry->remoterel.remoteid;
+		fkinfo->fkattnums = list_copy(fkattnums);
+		fkinfo->fkattnums_old = list_copy(fkattnums_old);
+		entry->local_fkeys = lappend(entry->local_fkeys, fkinfo);
+		MemoryContextSwitchTo(oldctx);
+
+		list_free(fkattnums);
+		list_free(fkattnums_old);
+	}
+
+	list_free_deep(fkeys);
+
+	/* Collect keys where this table is the referenced side */
+	collect_refkeys_for_dependency_tracking(entry);
+
+	entry->local_fkeys_collected = true;
+}
+
 /*
  * Check all local triggers for the relation to see the parallelizability.
  *
@@ -636,6 +1127,7 @@ logicalrep_rel_load(LogicalRepRelMapEntry *entry, LogicalRepRelId remoteid,
 			/* Table was renamed or dropped. */
 			entry->localrelvalid = false;
 			entry->local_unique_indexes_collected = false;
+			entry->local_fkeys_collected = false;
 		}
 		else if (!entry->localrelvalid)
 		{
@@ -755,6 +1247,9 @@ logicalrep_rel_load(LogicalRepRelMapEntry *entry, LogicalRepRelId remoteid,
 		entry->localrelvalid = true;
 	}
 
+	if (am_leader_apply_worker() && !entry->local_fkeys_collected)
+		collect_fkeys_for_dependency_tracking(entry);
+
 	if (entry->state != SUBREL_STATE_READY)
 		entry->state = GetSubscriptionRelState(MySubscription->oid,
 											   entry->localreloid,
@@ -1058,6 +1553,7 @@ logicalrep_partition_open(LogicalRepRelMapEntry *root,
 	 * collect_indexes_for_dependency_tracking() for details.)
 	 */
 	entry->local_unique_indexes_collected = true;
+	entry->local_fkeys_collected = true;
 
 	entry->localrelvalid = true;
 
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 17eaef28039..0f8f49409b9 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -584,7 +584,9 @@ static ApplySubXactData subxact_data = {0, 0, InvalidTransactionId, NULL};
 typedef enum LogicalRepKeyKind
 {
 	LOGICALREP_KEY_REPLICA_IDENTITY,
-	LOGICALREP_KEY_LOCAL_UNIQUE
+	LOGICALREP_KEY_LOCAL_UNIQUE,
+	LOGICALREP_KEY_FOREIGN_KEY,
+	LOGICALREP_KEY_REFERENCED_KEY
 } LogicalRepKeyKind;
 
 /* Hash table key for replica_identity_table */
@@ -780,9 +782,6 @@ hash_replica_identity_compare(ReplicaIdentityKey *a, ReplicaIdentityKey *b)
 		if (a->data->colstatus[i] != b->data->colstatus[i])
 			return false;
 
-		if (a->data->colstatus[i] == LOGICALREP_COLUMN_NULL)
-			continue;
-
 		if (a->data->colvalues[i].len != b->data->colvalues[i].len)
 			return false;
 
@@ -950,6 +949,24 @@ append_xid_dependency(TransactionId xid, List **depends_on_xids)
 	*depends_on_xids = lappend_xid(*depends_on_xids, xid);
 }
 
+static char *
+get_dependency_type_str(LogicalRepKeyKind kind)
+{
+	switch (kind)
+	{
+		case LOGICALREP_KEY_REPLICA_IDENTITY:
+			return "replica identity";
+		case LOGICALREP_KEY_LOCAL_UNIQUE:
+			return "local unique key";
+		case LOGICALREP_KEY_FOREIGN_KEY:
+			return "foreign key";
+		case LOGICALREP_KEY_REFERENCED_KEY:
+			return "referenced key";
+	}
+
+	return "???";
+}
+
 /*
  * Common function for checking dependency by using the key. Used by both
  * check_and_record_ri_dependency and check_and_record_local_key_dependency.
@@ -982,9 +999,8 @@ check_and_record_key_dependency(ReplicaIdentityKey *key,
 		if (rientry && has_active_key_dependency(rientry, true))
 		{
 			elog(DEBUG1,
-				 key->kind == LOGICALREP_KEY_REPLICA_IDENTITY ?
-				 "found conflicting replica identity change on table %u from %u" :
-				 "found conflicting local unique key change on table %u from %u",
+				 "found conflicting %s change on table %u from %u",
+				 get_dependency_type_str(key->kind),
 				 key->relid, rientry->remote_xid);
 
 			existing_xid = rientry->remote_xid;
@@ -1007,9 +1023,8 @@ check_and_record_key_dependency(ReplicaIdentityKey *key,
 		if (has_active_key_dependency(rientry, false))
 		{
 			elog(DEBUG1,
-				 key->kind == LOGICALREP_KEY_REPLICA_IDENTITY ?
-				 "found conflicting replica identity change on table %u from %u" :
-				 "found conflicting local unique key change on table %u from %u",
+				 "found conflicting %s change on table %u from %u",
+				 get_dependency_type_str(key->kind),
 				 key->relid, rientry->remote_xid);
 
 			existing_xid = rientry->remote_xid;
@@ -1039,6 +1054,23 @@ has_null_key_values(LogicalRepTupleData *data, Bitmapset *indexkeys)
 	return false;
 }
 
+/*
+ * Check if any of the attnums have NULL values.
+ */
+static bool
+fk_tuple_has_null(LogicalRepTupleData *data, List *attnums)
+{
+	foreach_int(attnum, attnums)
+	{
+		int		attidx = AttrNumberGetAttrOffset(attnum);
+
+		if (data->colstatus[attidx] == LOGICALREP_COLUMN_NULL)
+			return true;
+	}
+
+	return false;
+}
+
 /*
  * Build a hash key for replica_identity_table using the given relation and
  * tuple data, restricted to the specified key columns.
@@ -1104,6 +1136,72 @@ build_replica_identity_key(Oid relid, LogicalRepTupleData *original_data,
 	return key;
 }
 
+/*
+ * Build a hash key from explicitly ordered columns.
+ */
+static ReplicaIdentityKey *
+build_replica_identity_key_by_attnums(Oid relid,
+									  LogicalRepTupleData *original_data,
+									  List *attnums)
+{
+	LogicalRepTupleData *keydata;
+	ReplicaIdentityKey *key;
+	MemoryContext oldctx;
+	int	i_key = 0;
+	int nattnums = list_length(attnums);
+
+	oldctx = MemoryContextSwitchTo(ApplyContext);
+
+	keydata = palloc0_object(LogicalRepTupleData);
+
+	if (nattnums)
+	{
+		keydata->colvalues = palloc0_array(StringInfoData, nattnums);
+		keydata->colstatus = palloc0_array(char, nattnums);
+	}
+
+	keydata->ncols = nattnums;
+
+	foreach_int(attnum, attnums)
+	{
+		int		attidx = AttrNumberGetAttrOffset(attnum);
+
+		/*
+		 * For columns not exists in remote tuple, or outside of remote replica
+		 * identity in which case the remote column is marked as NULL, fill it
+		 * with NULL.
+		 */
+		if (attidx < 0 ||
+			original_data->colstatus[attidx] == LOGICALREP_COLUMN_NULL)
+		{
+			keydata->colstatus[i_key] = LOGICALREP_COLUMN_NULL;
+		}
+		else
+		{
+			StringInfo	original_colvalue;
+
+			Assert(original_data->colstatus[attidx] != LOGICALREP_COLUMN_UNCHANGED ||
+					   original_data->colvalues[attidx].len > 0);
+
+			original_colvalue = &original_data->colvalues[attidx];
+
+			initStringInfoExt(&keydata->colvalues[i_key], original_colvalue->len + 1);
+			appendStringInfoString(&keydata->colvalues[i_key], original_colvalue->data);
+			keydata->colstatus[i_key] = original_data->colstatus[attidx];
+		}
+
+		i_key++;
+	}
+
+	key = palloc0_object(ReplicaIdentityKey);
+	key->relid = relid;
+	key->data = keydata;
+
+	MemoryContextSwitchTo(oldctx);
+
+	return key;
+}
+
 /*
  * Mostly same as check_and_record_ri_dependency() but for local unique indexes.
  *
@@ -1199,6 +1297,152 @@ check_and_record_local_key_dependency(Oid relid,
 	}
 }
 
+/*
+ * Check and record dependencies caused by foreign key constraints.
+ *
+ * A new value in the referencing table depends on the referenced table's data.
+ * Conversely, a deletion in the referenced table depends on whether the
+ * referencing table still contains matching rows.
+ *
+ * See applyparallelworker.c for details on why tracking these dependencies is
+ * necessary.
+ */
+static void
+check_and_record_fkey_dependency(Oid relid,
+								 LogicalRepTupleData *original_data,
+								 bool old_tuple,
+								 TransactionId new_depended_xid,
+								 List **depends_on_xids)
+{
+	LogicalRepRelMapEntry *relentry;
+	ReplicaIdentityKey *rikey;
+
+	Assert(depends_on_xids);
+
+	relentry = logicalrep_get_relentry(relid);
+
+	Assert(relentry);
+
+	/*
+	 * Gather foreign key information if not yet. We require to be in a
+	 * transaction state because system catalogs are read.
+	 */
+	if (!relentry->local_fkeys_collected)
+	{
+		bool		needs_start = !IsTransactionOrTransactionBlock();
+
+		if (needs_start)
+			StartTransactionCommand();
+
+		logicalrep_rel_load(NULL, relid, AccessShareLock);
+
+		if (needs_start)
+			CommitTransactionCommand();
+
+		Assert(relentry->local_fkeys_collected);
+	}
+
+	foreach_ptr(LogicalRepSubscriberFK, fkinfo, relentry->local_fkeys)
+	{
+		List	   *keyattnums = old_tuple ?
+			fkinfo->fkattnums_old : fkinfo->fkattnums;
+
+		/* NULL value won't violate foreign key violation */
+		if (fk_tuple_has_null(original_data, keyattnums))
+			continue;
+
+		/*
+		 * Old tuples of foreign keys do not conflict with any preceding
+		 * transaction (see the comments in applyparallelworker.c for details on
+		 * conflicting cases). When we don't need to record a new dependency, we
+		 * can skip processing this index entirely.
+		 */
+		if (old_tuple && !TransactionIdIsValid(new_depended_xid))
+			continue;
+
+		rikey = build_replica_identity_key_by_attnums(fkinfo->ref_remoteid,
+													  original_data,
+													  keyattnums);
+
+		/*
+		 * For old tuples, record a dependency that later transactions deleting
+		 * the same key from the referenced table must wait on. No preceding
+		 * transactions are added to the list.
+		 *
+		 * For new tuples, check for existing dependencies on the same key
+		 * inserted into the referenced table and add any dependent transactions
+		 * to the list.
+		 */
+		if (old_tuple)
+		{
+			rikey->kind = LOGICALREP_KEY_FOREIGN_KEY;
+			(void) check_and_record_key_dependency(rikey, new_depended_xid);
+		}
+		else
+		{
+			TransactionId	dep_xid;
+
+			rikey->kind = LOGICALREP_KEY_REFERENCED_KEY;
+			dep_xid = check_and_record_key_dependency(rikey, InvalidTransactionId);
+
+			if (TransactionIdIsValid(dep_xid) &&
+				!TransactionIdEquals(dep_xid, new_depended_xid))
+				append_xid_dependency(dep_xid, depends_on_xids);
+		}
+	}
+
+	/* Return if no referenced key exists */
+	if (relentry->local_referenced_fkeys == NIL)
+		return;
+
+	foreach_ptr(LogicalRepSubscriberRefFK, refinfo, relentry->local_referenced_fkeys)
+	{
+		List	   *keyattnums = old_tuple ?
+			refinfo->refattnums_old : refinfo->refattnums;
+
+		/* Shouldn't be real NULL value in referenced key (primary key). */
+		Assert(old_tuple || !fk_tuple_has_null(original_data, keyattnums));
+
+		/*
+		 * New tuples of referenced keys do not conflict with any preceding
+		 * transaction (see the comments in applyparallelworker.c for details on
+		 * conflicting cases). When we don't need to record a new dependency, we
+		 * can skip processing this key entirely.
+		 */
+		if (!old_tuple && !TransactionIdIsValid(new_depended_xid))
+			continue;
+
+		rikey = build_replica_identity_key_by_attnums(relid, original_data,
+													  keyattnums);
+
+		/*
+		 * For old tuples, check for existing dependencies on the same key
+		 * deleted from the referencing table and add any dependent transactions
+		 * to the list.
+		 *
+		 * For new tuples, record a dependency that later transactions inserting
+		 * the same key into the referencing table must wait on. No preceding
+		 * transactions are added to the list.
+		 */
+		if (old_tuple)
+		{
+			TransactionId	dep_xid;
+
+			rikey->kind = LOGICALREP_KEY_FOREIGN_KEY;
+			dep_xid = check_and_record_key_dependency(rikey, InvalidTransactionId);
+
+			if (TransactionIdIsValid(dep_xid) &&
+				!TransactionIdEquals(dep_xid, new_depended_xid))
+				append_xid_dependency(dep_xid, depends_on_xids);
+		}
+		else
+		{
+			rikey->kind = LOGICALREP_KEY_REFERENCED_KEY;
+			(void) check_and_record_key_dependency(rikey, new_depended_xid);
+		}
+	}
+}
+
 /*
  * Check for dependencies on preceding transactions that modify the same key.
  * Returns the dependent transactions in 'depends_on_xids'.
@@ -1464,6 +1708,9 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			check_and_record_local_key_dependency(relid, &newtup, false,
 												  new_depended_xid,
 												  &depends_on_xids);
+			check_and_record_fkey_dependency(relid, &newtup, false,
+											 new_depended_xid,
+											 &depends_on_xids);
 			check_dependency_for_parallel_safety(relid, new_depended_xid,
 												 &depends_on_xids);
 			break;
@@ -1481,6 +1728,10 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 													  new_depended_xid,
 													  &depends_on_xids);
 
+				check_and_record_fkey_dependency(relid, &oldtup, true,
+												 new_depended_xid,
+												 &depends_on_xids);
+
 				check_dependency_for_parallel_safety(relid, new_depended_xid,
 													 &depends_on_xids);
 
@@ -1505,6 +1756,9 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			check_and_record_local_key_dependency(relid, &newtup, false,
 												  new_depended_xid,
 												  &depends_on_xids);
+			check_and_record_fkey_dependency(relid, &newtup, false,
+											 new_depended_xid,
+											 &depends_on_xids);
 			check_dependency_for_parallel_safety(relid, new_depended_xid,
 												 &depends_on_xids);
 			break;
@@ -1516,6 +1770,9 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			check_and_record_local_key_dependency(relid, &oldtup, true,
 												  new_depended_xid,
 												  &depends_on_xids);
+			check_and_record_fkey_dependency(relid, &oldtup, true,
+											 new_depended_xid,
+											 &depends_on_xids);
 			check_dependency_for_parallel_safety(relid, new_depended_xid,
 												 &depends_on_xids);
 			break;
@@ -1552,6 +1809,29 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			 */
 			check_and_record_rel_dependency(rel->remoteid, new_depended_xid,
 											&depends_on_xids);
+
+			remote_relids = logicalrep_get_fk_related_relids(rel);
+
+			/*
+			 * Wait for all pending changes on tables that reference or are
+			 * referenced by the current table. Invalidate their cached foreign
+			 * key information, as it may be outdated due to schema changes
+			 * (e.g., replica identity) on the current table.
+			 */
+			foreach_oid(related_relid, remote_relids)
+			{
+				LogicalRepRelMapEntry *fk_related_entry;
+
+				check_and_record_rel_dependency(related_relid,
+												new_depended_xid,
+												&depends_on_xids);
+
+				fk_related_entry = logicalrep_get_relentry(related_relid);
+
+				if (fk_related_entry)
+					fk_related_entry->local_fkeys_collected = false;
+			}
+
 			break;
 
 		case LOGICAL_REP_MSG_TYPE:
diff --git a/src/include/replication/logicalrelation.h b/src/include/replication/logicalrelation.h
index fe38862b300..abe15865779 100644
--- a/src/include/replication/logicalrelation.h
+++ b/src/include/replication/logicalrelation.h
@@ -50,6 +50,11 @@ typedef struct LogicalRepRelMapEntry
 	List	   *local_unique_indexes;
 	bool		local_unique_indexes_collected;
 
+	/* Local foreign keys. Used for dependency tracking */
+	List	   *local_fkeys;
+	List	   *local_referenced_fkeys;
+	bool		local_fkeys_collected;
+
 	/*
 	 * Whether the relation can be applied in parallel or not. It is
 	 * distinglish whether defined triggers are the immutable or not.
@@ -73,6 +78,22 @@ typedef struct LogicalRepSubscriberIdx
 	bool		nulls_distinct;	/* Whether NULLs are considered distinct */
 } LogicalRepSubscriberIdx;
 
+typedef struct LogicalRepSubscriberFK
+{
+	Oid			conoid;		/* OID of the FK constraint */
+	LogicalRepRelId ref_remoteid; /* referenced remote relation */
+	List	   *fkattnums;	/* FK remote attnums ordered by referenced key */
+	List	   *fkattnums_old; /* old-tuple-safe FK remote attnums */
+} LogicalRepSubscriberFK;
+
+typedef struct LogicalRepSubscriberRefFK
+{
+	Oid			conoid;		/* OID of the FK constraint */
+	LogicalRepRelId fk_remoteid; /* referencing remote relation */
+	List	   *refattnums;	/* referenced remote attnums */
+	List	   *refattnums_old; /* old-tuple-safe referenced remote attnums */
+} LogicalRepSubscriberRefFK;
+
 extern void logicalrep_relmap_update(LogicalRepRelation *remoterel);
 extern void logicalrep_partmap_reset_relmap(LogicalRepRelation *remoterel);
 
@@ -89,6 +110,7 @@ extern Oid	GetRelationIdentityOrPK(Relation rel);
 extern int	logicalrep_get_num_rels(void);
 extern void logicalrep_write_all_rels(StringInfo out);
 extern LogicalRepRelMapEntry *logicalrep_get_relentry(LogicalRepRelId remoteid);
+extern List *logicalrep_get_fk_related_relids(LogicalRepRelation *remoteid);
 
 #define LOGICALREP_PARALLEL_SAFE		's'
 #define LOGICALREP_PARALLEL_RESTRICTED	'r'
diff --git a/src/test/subscription/t/050_parallel_apply.pl b/src/test/subscription/t/050_parallel_apply.pl
index 6b0d5c7e383..81550c71ba0 100644
--- a/src/test/subscription/t/050_parallel_apply.pl
+++ b/src/test/subscription/t/050_parallel_apply.pl
@@ -33,6 +33,9 @@ $node_publisher->safe_psql(
     CREATE UNIQUE INDEX tab_toast_ri_index on tab_toast (a, b);
     ALTER TABLE tab_toast REPLICA IDENTITY USING INDEX tab_toast_ri_index;
     INSERT INTO tab_toast(a, b) VALUES(repeat('1234567890', 200), '1234567890');
+
+    CREATE TABLE regress_tab_pk (id int PRIMARY KEY);
+    CREATE TABLE regress_tab_fk (id int PRIMARY KEY, fk int REFERENCES regress_tab_pk (id));
 ));
 $node_publisher->safe_psql('postgres',
     "INSERT INTO regress_tab VALUES (generate_series(1, 10), 'test');");
@@ -71,6 +74,9 @@ $node_subscriber->safe_psql(
     ALTER TABLE tab_toast ALTER COLUMN a SET STORAGE EXTERNAL;
     CREATE UNIQUE INDEX tab_toast_ri_index on tab_toast (a, b);
     ALTER TABLE tab_toast REPLICA IDENTITY USING INDEX tab_toast_ri_index;
+
+    CREATE TABLE regress_tab_pk (id int PRIMARY KEY);
+    CREATE TABLE regress_tab_fk (id int PRIMARY KEY, fk int REFERENCES regress_tab_pk (id));
 ));
 $node_subscriber->safe_psql('postgres',
     "CREATE SUBSCRIPTION regress_sub CONNECTION '$publisher_connstr' PUBLICATION regress_pub;");
@@ -527,4 +533,162 @@ $node_subscriber->safe_psql('postgres', "DROP INDEX local_unique_idx_expr;");
 $node_publisher->safe_psql('postgres', "TRUNCATE TABLE regress_tab;");
 $node_publisher->wait_for_catchup('regress_sub');
 
+##################################################
+# Test that the dependency tracking works correctly for foreign keys on
+# subscriber during parallel apply.
+##################################################
+
+# Test that when receiving table schema information for a referencing table, the
+# subscriber correctly checks for dependencies on the referenced table and waits
+# for its changes to complete.
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+# Enable foreign triggers on subscriber
+my $pk_fk_trigger = $node_subscriber->safe_psql('postgres', qq[
+    SELECT tgname
+    FROM pg_trigger
+    WHERE tgrelid = 'regress_tab_pk'::regclass
+      AND tgconstraint > 0
+    LIMIT 1
+]);
+chomp($pk_fk_trigger);
+
+my $fk_fk_trigger = $node_subscriber->safe_psql('postgres', qq[
+    SELECT tgname
+    FROM pg_trigger
+    WHERE tgrelid = 'regress_tab_fk'::regclass
+      AND tgconstraint > 0
+    LIMIT 1
+]);
+chomp($fk_fk_trigger);
+
+$node_subscriber->safe_psql('postgres',
+    qq[ALTER TABLE regress_tab_pk ENABLE REPLICA TRIGGER "$pk_fk_trigger";
+    ALTER TABLE regress_tab_fk ENABLE REPLICA TRIGGER "$fk_fk_trigger";]);
+
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab_pk VALUES (2);");
+
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+$offset = -s $node_subscriber->logfile;
+
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab_fk VALUES (1, 2);");
+
+# Verify the dependency is detected on referenced table schema information
+$str = $node_subscriber->wait_for_log(qr/found conflicting change on table [1-9][0-9]+ from ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /found conflicting change on table [1-9][0-9]+ from ([1-9][0-9]+)/;
+
+# Verify the parallel worker waits for the same transaction
+$node_subscriber->wait_for_log(qr/wait for depended xid $xid/, $offset);
+
+ok(1, "referenced table dependency detected for parallel apply");
+
+# Wakeup the parallel worker
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+# Verify the streamed transaction can be applied
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+$node_publisher->wait_for_catchup('regress_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab_pk");
+is ($result, 1, 'insert is replicated to referenced table on subscriber');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab_fk");
+is ($result, 1, 'insert is replicated to referencing table on subscriber');
+
+# INSERT - INSERT case: Tx-1 inserts referenced tuple and Tx-2 inserts
+# referencing tuple.
+
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab_pk VALUES (3);");
+
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+$offset = -s $node_subscriber->logfile;
+
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab_fk VALUES (2, 3);");
+
+# Verify the dependency for referenced key change is detected
+$str = $node_subscriber->wait_for_log(qr/found conflicting referenced key change on table [1-9][0-9]+ from ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /found conflicting referenced key change on table [1-9][0-9]+ from ([1-9][0-9]+)/;
+
+# Verify the parallel worker waits for the same transaction
+$node_subscriber->wait_for_log(qr/wait for depended xid $xid/, $offset);
+
+ok(1, "referenced key dependency detected for parallel apply");
+
+# Wakeup the parallel worker
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+# Verify the streamed transaction can be applied
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+$node_publisher->wait_for_catchup('regress_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab_pk");
+is ($result, 2, 'insert is replicated to referenced table on subscriber');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab_fk");
+is ($result, 2, 'insert is replicated to referencing table on subscriber');
+
+# DELETE - DELETE case: Tx-1 deletes referencing tuple and Tx-2 deletes
+# referenced tuple.
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+$node_publisher->safe_psql('postgres',
+    "DELETE FROM regress_tab_fk WHERE id = 1;");
+
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+$offset = -s $node_subscriber->logfile;
+
+$node_publisher->safe_psql('postgres',
+    "DELETE FROM regress_tab_pk WHERE id = 2;");
+
+$str = $node_subscriber->wait_for_log(qr/found conflicting foreign key change on table [1-9][0-9]+ from ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /found conflicting foreign key change on table [1-9][0-9]+ from ([1-9][0-9]+)/;
+
+$node_subscriber->wait_for_log(qr/wait for depended xid $xid/, $offset);
+
+ok(1, "foreign key dependency detected for parallel apply");
+
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+$node_publisher->wait_for_catchup('regress_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab_pk");
+is ($result, 1, 'delete is replicated to referenced table on subscriber');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab_fk");
+is ($result, 1, 'delete is replicated to referencing table on subscriber');
+
 done_testing();
-- 
2.43.0



  [application/octet-stream] v20-0008-Support-dependency-tracking-via-local-unique-ind.patch (38.8K, ../../TY4PR01MB1771875A7A64B139EF8C8D05C94E62@TY4PR01MB17718.jpnprd01.prod.outlook.com/4-v20-0008-Support-dependency-tracking-via-local-unique-ind.patch)
  download | inline diff:
From 239b5645fdbe0d5d6eb9431be0feb5d198a8ab89 Mon Sep 17 00:00:00 2001
From: Zhijie Hou <[email protected]>
Date: Wed, 10 Jun 2026 13:03:16 +0800
Subject: [PATCH v20 8/9] Support dependency tracking via local unique indexes

Currently, logical replication's parallel apply mechanism tracks dependencies
primarily based on the REPLICA IDENTITY defined on the publisher table.
However, local subscriber tables might have additional unique indexes that
could effectively serve as dependency keys, even if they don't correspond to
the publisher's REPLICA IDENTITY. Failing to track these additional unique
keys can lead to incorrect data and/or deadlocks during parallel application.

This patch extends the parallel apply's dependency tracking to consider
local unique indexes on the subscriber table. This is achieved by extending
the existing Replica Identity hash table to also store dependency information
based on these local unique indexes.

The LogicalRepRelMapEntry structure is extended to store details about these
local unique indexes. This information is collected and cached when
dependency checking is first performed for a remote transaction on a given
relation. This collection process requires to be in a transaction to access
system catalog information.
---
 .../replication/logical/applyparallelworker.c |  50 +++
 src/backend/replication/logical/relation.c    | 196 ++++++++-
 src/backend/replication/logical/worker.c      | 387 +++++++++++++-----
 src/backend/storage/lmgr/deadlock.c           |   1 -
 src/include/replication/logicalrelation.h     |  12 +
 src/test/subscription/t/050_parallel_apply.pl | 124 ++++++
 src/tools/pgindent/typedefs.list              |   2 +
 7 files changed, 667 insertions(+), 105 deletions(-)

diff --git a/src/backend/replication/logical/applyparallelworker.c b/src/backend/replication/logical/applyparallelworker.c
index 58bd7a69992..8826faf102c 100644
--- a/src/backend/replication/logical/applyparallelworker.c
+++ b/src/backend/replication/logical/applyparallelworker.c
@@ -174,6 +174,56 @@
  * both are allowed to apply in parallel, TX-2's DELETE could be applied before
  * TX-1's INSERT, resulting in a delete_missing conflict.
  *
+ * Beyond replica identity keys, we also track dependencies on transactions that
+ * modify the same local unique key. Even if the replica identity keys differ,
+ * unique indexes can still cause conflicts. This is necessary to prevent
+ * unexpected errors. For example:
+ *
+ *   TX-1: DELETE row (1,2) with replica identity key (1,2) and unique key (2)
+ *   TX-2: INSERT row (3,2) with replica identity key (3,2) and unique key (2)
+ *
+ * If applied in parallel, TX-2's INSERT could be applied before TX-1's DELETE,
+ * leading to a unique index violation error.
+ *
+ * We do not track dependencies for INSERT and UPDATE that conflict on a new
+ * unique key value, since such conflicts would cause an error even in serial
+ * mode. Instead, we only track dependencies involving old tuples (from DELETE
+ * or UPDATE) and require INSERT and UPDATE transactions that target the same
+ * unique key to wait for them.
+ *
+ * Note that the old tuple of an UPDATE or DELETE may not include the unique key
+ * column if that column is not part of the replica identity columns on the
+ * publisher. In such cases, we only use the unique columns that are part of the
+ * replica identity keys for dependency tracking, which may lead to false
+ * positives. For example, consider a unique index defined as UNIQUE (a, b),
+ * where only b is part of the replica identity keys:
+ *
+ *   TX-1: DELETE row (1,2) TX-2: INSERT row (3,2)
+ *
+ * If applied in parallel, both transactions will be treated as dependent
+ * because they modify the same unique key value (b=2), even though they
+ * actually modify different unique keys. This is acceptable because it is still
+ * better than completely disallowing parallelism for these transactions.
+ *
+ * In the worst case, if none of the unique index columns are part of the
+ * replica identity keys, we treat all transactions that modify the same table
+ * as dependent and disallow parallelism for that table.
+ *
+ * XXX We could consider requesting the publisher to include unique key columns
+ * in the old tuple of UPDATE or DELETE when they are not part of the replica
+ * identity keys. This would reduce false positives, but would require changes
+ * on the publisher side and increase disk (WAL size) and network data. For now,
+ * we choose not to implement this. An alternative approach is to provide an
+ * option to skip tracking dependencies on unique keys that are not part of the
+ * replica identity keys. This could be useful for users who prefer higher
+ * parallelism and experience few conflicts.
+ *
+ * Note that the local unique key could change after dependency checking and
+ * before applying the change. However, to centralize tracking and keep it
+ * simple, we still perform this check only in the leader apply worker. This is
+ * acceptable because in the worst case, the parallel worker will report an
+ * error and restart the transaction using the latest index information.
+ *
  * Commit order
  * ------------
  * We preserve publisher commit order for all transactions for two reasons:
diff --git a/src/backend/replication/logical/relation.c b/src/backend/replication/logical/relation.c
index eeb85f8cc5d..8eef87ca910 100644
--- a/src/backend/replication/logical/relation.c
+++ b/src/backend/replication/logical/relation.c
@@ -84,6 +84,7 @@ logicalrep_relmap_invalidate_cb(Datum arg, Oid reloid)
 			if (entry->localreloid == reloid)
 			{
 				entry->localrelvalid = false;
+				entry->local_unique_indexes_collected = false;
 				hash_seq_term(&status);
 				break;
 			}
@@ -97,7 +98,10 @@ logicalrep_relmap_invalidate_cb(Datum arg, Oid reloid)
 		hash_seq_init(&status, LogicalRepRelMap);
 
 		while ((entry = (LogicalRepRelMapEntry *) hash_seq_search(&status)) != NULL)
+		{
 			entry->localrelvalid = false;
+			entry->local_unique_indexes_collected = false;
+		}
 	}
 }
 
@@ -128,6 +132,21 @@ logicalrep_relmap_init(void)
 								  (Datum) 0);
 }
 
+/*
+ * Release local index list
+ */
+static void
+free_local_unique_indexes(LogicalRepRelMapEntry *entry)
+{
+	Assert(am_leader_apply_worker());
+
+	foreach_ptr(LogicalRepSubscriberIdx, idxinfo, entry->local_unique_indexes)
+		bms_free(idxinfo->indexkeys);
+
+	list_free_deep(entry->local_unique_indexes);
+	entry->local_unique_indexes = NIL;
+}
+
 /*
  * Free the entry of a relation map cache.
  */
@@ -155,6 +174,9 @@ logicalrep_relmap_free_entry(LogicalRepRelMapEntry *entry)
 
 	if (entry->attrmap)
 		free_attrmap(entry->attrmap);
+
+	if (entry->local_unique_indexes != NIL)
+		free_local_unique_indexes(entry);
 }
 
 /*
@@ -217,6 +239,7 @@ logicalrep_relmap_update(LogicalRepRelation *remoterel)
 	entry->remoterel.attkeys = bms_copy(remoterel->attkeys);
 
 	entry->parallel_safe = LOGICALREP_PARALLEL_UNKNOWN;
+	entry->local_unique_indexes_collected = false;
 	MemoryContextSwitchTo(oldctx);
 }
 
@@ -361,6 +384,154 @@ logicalrep_rel_mark_updatable(LogicalRepRelMapEntry *entry)
 	}
 }
 
+/*
+ * Collect all local unique indexes that can be used for dependency tracking
+ *
+ * This function collects all types of unique indexes, including those with
+ * index expressions and partial indexes. However, to avoid the overhead and
+ * complexity of executing expressions, we do not evaluate them during
+ * dependency tracking.
+ *
+ * For indexes with expressions, only the non-expression columns are recorded in
+ * the bitmap. The dependency tracking function will use only these columns,
+ * which may lead to false dependency detection. For example, consider a unique
+ * index defined as UNIQUE (a, func(b)), where b is an expression column. Rows
+ * (1, 2) and (1, 3) will be treated as dependent even though they are not. This
+ * is acceptable, as it is still better than disabling parallelism for all
+ * relations that have expression indexes.
+ *
+ * Similarly, partial indexes may also cause false dependencies due to predicate
+ * expressions. For the same reason, we consider this acceptable as well.
+ *
+ * To avoid redundant dependency tracking, indexes whose key columns are the
+ * same as, or a superset of, the replica identity key columns are skipped,
+ * since tracking the replica identity keys already covers their scope.
+ *
+ * Columns not in the replica identity key are excluded from the unique column
+ * set. Since the old tuple of an UPDATE or DELETE contains only replica
+ * identity key columns, any other columns would be missing and thus unavailable
+ * for dependency tracking.
+ */
+static void
+collect_indexes_for_dependency_tracking(LogicalRepRelMapEntry *entry)
+{
+	List	   *idxlist;
+
+	free_local_unique_indexes(entry);
+
+	/*
+	 * XXX For partitioned tables, we must collect unique indexes from leaf
+	 * partitions, which are the actual replication targets. This is because
+	 * leaf partitions can have unique indexes that are not present on the
+	 * partitioned table, and those indexes can be used for dependency tracking.
+	 * However, collecting unique indexes from leaf partitions requires building
+	 * the tuple, and executing partition pruning expressions, which could be
+	 * expensive for each change on a partitioned table. For now, we skip
+	 * collecting local unique indexes for partitioned tables and create a dummy
+	 * entry, ensuring that changes on partitioned tables are not applied in
+	 * parallel.
+	 */
+	if (entry->localrel->rd_rel->relkind == RELKIND_PARTITIONED_TABLE)
+	{
+		MemoryContext oldctx;
+		LogicalRepSubscriberIdx *indexinfo;
+
+		oldctx = MemoryContextSwitchTo(LogicalRepRelMapContext);
+		indexinfo = palloc(sizeof(LogicalRepSubscriberIdx));
+		indexinfo->indexoid = InvalidOid;
+		indexinfo->indexkeys = NULL;
+		indexinfo->nulls_distinct = false;
+		entry->local_unique_indexes = lappend(entry->local_unique_indexes,
+											  indexinfo);
+		MemoryContextSwitchTo(oldctx);
+
+		entry->local_unique_indexes_collected = true;
+
+		return;
+	}
+
+	idxlist = RelationGetIndexList(entry->localrel);
+
+	/* Iterate indexes to list all usable indexes */
+	foreach_oid(idxoid, idxlist)
+	{
+		Relation	idxrel;
+		int			indnkeys;
+		AttrMap    *attrmap;
+		MemoryContext oldctx;
+		LogicalRepSubscriberIdx *indexinfo;
+		Bitmapset  *indexkeys = NULL;
+		bool		nulls_distinct;
+
+		idxrel = index_open(idxoid, AccessShareLock);
+
+		/* Only unique indexes are considered */
+		if (!idxrel->rd_index->indisunique)
+		{
+			index_close(idxrel, AccessShareLock);
+			continue;
+		}
+
+		indnkeys = idxrel->rd_index->indnkeyatts;
+		nulls_distinct = !idxrel->rd_index->indnullsnotdistinct;
+		attrmap = entry->attrmap;
+
+		Assert(indnkeys);
+
+		/* Seek each attributes and add to a Bitmap */
+		for (int i = 0; i < indnkeys; i++)
+		{
+			AttrNumber	localcol = idxrel->rd_index->indkey.values[i];
+			AttrNumber	remotecol;
+
+			/* Skip expression */
+			if (!AttributeNumberIsValid(localcol))
+				continue;
+
+			remotecol = attrmap->attnums[AttrNumberGetAttrOffset(localcol)];
+
+			/* Skip if the column does not exist on publisher node */
+			if (remotecol < 0)
+				continue;
+
+			/* Skip columns that are not part of the replica identity key */
+			if (!bms_is_member(remotecol, entry->remoterel.attkeys))
+				continue;
+
+			/* Checks are passed, remember the attribute */
+			indexkeys = bms_add_member(indexkeys, remotecol);
+		}
+
+		index_close(idxrel, AccessShareLock);
+
+		/*
+		 * Skip indexes whose key columns are a superset of the replica identity
+		 * key.
+		 */
+		if (bms_equal(entry->remoterel.attkeys, indexkeys) ||
+			bms_is_subset(entry->remoterel.attkeys, indexkeys))
+		{
+			bms_free(indexkeys);
+			continue;
+		}
+
+		oldctx = MemoryContextSwitchTo(LogicalRepRelMapContext);
+		indexinfo = palloc(sizeof(LogicalRepSubscriberIdx));
+		indexinfo->indexoid = idxoid;
+		indexinfo->indexkeys = bms_copy(indexkeys);
+		indexinfo->nulls_distinct = nulls_distinct;
+		entry->local_unique_indexes = lappend(entry->local_unique_indexes,
+											  indexinfo);
+		MemoryContextSwitchTo(oldctx);
+
+		bms_free(indexkeys);
+	}
+
+	list_free(idxlist);
+
+	entry->local_unique_indexes_collected = true;
+}
+
 /*
  * Check all local triggers for the relation to see the parallelizability.
  *
@@ -370,7 +541,16 @@ logicalrep_rel_mark_updatable(LogicalRepRelMapEntry *entry)
 static void
 check_defined_triggers(LogicalRepRelMapEntry *entry)
 {
-	TriggerDesc *trigdesc = entry->localrel->trigdesc;
+	TriggerDesc *trigdesc;
+
+	/*
+	 * Skip if the parallelizability has already been checked. Possilble if
+	 * the relation has expression indexes.
+	 */
+	if (entry->parallel_safe != LOGICALREP_PARALLEL_UNKNOWN)
+		return;
+
+	trigdesc = entry->localrel->trigdesc;
 
 	/* Quick exit if triffer is not defined */
 	if (trigdesc == NULL)
@@ -411,7 +591,7 @@ check_defined_triggers(LogicalRepRelMapEntry *entry)
  * If the key is given, the corresponding entry is first searched in the hash
  * table and processed as in the above case. At the end, logical replication is
  * closed.
-  */
+ */
 void
 logicalrep_rel_load(LogicalRepRelMapEntry *entry, LogicalRepRelId remoteid,
 					LOCKMODE lockmode)
@@ -455,6 +635,7 @@ logicalrep_rel_load(LogicalRepRelMapEntry *entry, LogicalRepRelId remoteid,
 		{
 			/* Table was renamed or dropped. */
 			entry->localrelvalid = false;
+			entry->local_unique_indexes_collected = false;
 		}
 		else if (!entry->localrelvalid)
 		{
@@ -565,7 +746,11 @@ logicalrep_rel_load(LogicalRepRelMapEntry *entry, LogicalRepRelId remoteid,
 		 * tracking.
 		 */
 		if (am_leader_apply_worker())
+		{
+			entry->parallel_safe = LOGICALREP_PARALLEL_UNKNOWN;
+			collect_indexes_for_dependency_tracking(entry);
 			check_defined_triggers(entry);
+		}
 
 		entry->localrelvalid = true;
 	}
@@ -867,6 +1052,13 @@ logicalrep_partition_open(LogicalRepRelMapEntry *root,
 	entry->localindexoid = FindLogicalRepLocalIndex(partrel, remoterel,
 													entry->attrmap);
 
+	/*
+	 * TODO: Parallel apply cannot collect indexes from leaf partition for now.
+	 * Just mark local indexes are collected. (See
+	 * collect_indexes_for_dependency_tracking() for details.)
+	 */
+	entry->local_unique_indexes_collected = true;
+
 	entry->localrelvalid = true;
 
 	return entry;
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 717cf74f0ac..17eaef28039 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -578,10 +578,20 @@ typedef struct ApplySubXactData
 
 static ApplySubXactData subxact_data = {0, 0, InvalidTransactionId, NULL};
 
+/*
+ * Type of key used for dependency tracking.
+ */
+typedef enum LogicalRepKeyKind
+{
+	LOGICALREP_KEY_REPLICA_IDENTITY,
+	LOGICALREP_KEY_LOCAL_UNIQUE
+} LogicalRepKeyKind;
+
 /* Hash table key for replica_identity_table */
 typedef struct ReplicaIdentityKey
 {
 	Oid			relid;
+	LogicalRepKeyKind kind;
 	LogicalRepTupleData *data;
 } ReplicaIdentityKey;
 
@@ -761,7 +771,8 @@ static bool
 hash_replica_identity_compare(ReplicaIdentityKey *a, ReplicaIdentityKey *b)
 {
 	if (a->relid != b->relid ||
-		a->data->ncols != b->data->ncols)
+		a->data->ncols != b->data->ncols ||
+		a->kind != b->kind)
 		return false;
 
 	for (int i = 0; i < a->data->ncols; i++)
@@ -769,6 +780,9 @@ hash_replica_identity_compare(ReplicaIdentityKey *a, ReplicaIdentityKey *b)
 		if (a->data->colstatus[i] != b->data->colstatus[i])
 			return false;
 
+		if (a->data->colstatus[i] == LOGICALREP_COLUMN_NULL)
+			continue;
+
 		if (a->data->colvalues[i].len != b->data->colvalues[i].len)
 			return false;
 
@@ -789,8 +803,12 @@ free_replica_identity_key(ReplicaIdentityKey *key)
 {
 	Assert(key);
 
-	pfree(key->data->colvalues);
-	pfree(key->data->colstatus);
+	if (key->data->colvalues)
+		pfree(key->data->colvalues);
+
+	if (key->data->colstatus)
+		pfree(key->data->colstatus);
+
 	pfree(key->data);
 	pfree(key);
 }
@@ -933,71 +951,124 @@ append_xid_dependency(TransactionId xid, List **depends_on_xids)
 }
 
 /*
- * Check for dependencies on preceding transactions that modify the same key as
- * the given tuple. Returns the dependent transactions in 'depends_on_xids'.
+ * Common function for checking dependency by using the key. Used by both
+ * check_and_record_ri_dependency and check_and_record_local_key_dependency.
+ *
+ * Check whether the given key has an active dependency. If new_depended_xid is
+ * valid, also records a new dependency for that transaction.
  *
- * Additionally, if new_depended_xid is valid, record the current change and the
- * transaction as a new dependency for the replica identity key modification,
- * allowing subsequent transactions that modify the same key to be dependent on
- * it.
+ * Return the existing transaction ID if an active dependency exists for the
+ * key; otherwise returns InvalidTransactionId.
  */
-static void
-check_and_record_ri_dependency(Oid relid, LogicalRepTupleData *original_data,
-							   TransactionId new_depended_xid,
-							   List **depends_on_xids)
+static TransactionId
+check_and_record_key_dependency(ReplicaIdentityKey *key,
+								TransactionId new_depended_xid)
 {
-	LogicalRepRelMapEntry *relentry;
-	LogicalRepTupleData *ridata;
-	ReplicaIdentityKey *rikey;
+	TransactionId existing_xid = InvalidTransactionId;
 	ReplicaIdentityEntry *rientry;
-	MemoryContext oldctx;
-	int			n_ri;
 	bool		found = false;
 
-	Assert(depends_on_xids);
+	/*
+	 * The new xid could be invalid if the transaction will be applied by the
+	 * leader itself which means all the changes will be committed before
+	 * processing next transaction. In this case, we only need to check for
+	 * dependencies on preceding transactions, there is no need to record a new
+	 * dependency for subsequent transactions to wait on.
+	 */
+	if (!TransactionIdIsValid(new_depended_xid))
+	{
+		rientry = replica_identity_lookup(replica_identity_table, key);
 
-	/* Search for existing entry */
-	relentry = logicalrep_get_relentry(relid);
+		if (rientry && has_active_key_dependency(rientry, true))
+		{
+			elog(DEBUG1,
+				 key->kind == LOGICALREP_KEY_REPLICA_IDENTITY ?
+				 "found conflicting replica identity change on table %u from %u" :
+				 "found conflicting local unique key change on table %u from %u",
+				 key->relid, rientry->remote_xid);
 
-	Assert(relentry);
+			existing_xid = rientry->remote_xid;
+		}
+
+		free_replica_identity_key(key);
+
+		return existing_xid;
+	}
+
+	/* Record a new dependency for subsequent transactions to wait on */
+	rientry = replica_identity_insert(replica_identity_table, key,
+									  &found);
 
 	/*
-	 * First check whether any previous transaction (other than the current one)
-	 * has affected the whole table e.g., truncate or schema change from
-	 * publisher.
+	 * Release the key built to search the entry, if the entry already exists.
 	 */
-	if (has_active_rel_dependency(relentry) &&
-		!TransactionIdEquals(relentry->last_depended_xid, new_depended_xid))
+	if (found)
 	{
-		elog(DEBUG1, "found table-wide change affecting %u from %u",
-			 relid, relentry->last_depended_xid);
+		if (has_active_key_dependency(rientry, false))
+		{
+			elog(DEBUG1,
+				 key->kind == LOGICALREP_KEY_REPLICA_IDENTITY ?
+				 "found conflicting replica identity change on table %u from %u" :
+				 "found conflicting local unique key change on table %u from %u",
+				 key->relid, rientry->remote_xid);
 
-		append_xid_dependency(relentry->last_depended_xid, depends_on_xids);
+			existing_xid = rientry->remote_xid;
+		}
+
+		free_replica_identity_key(key);
 	}
 
-	n_ri = bms_num_members(relentry->remoterel.attkeys);
+	rientry->remote_xid = new_depended_xid;
 
-	/*
-	 * Return if there are no replica identity columns, indicating that the
-	 * remote relation has neither a replica identity key nor is marked as
-	 * replica identity full.
-	 */
-	if (!n_ri)
-		return;
+	return existing_xid;
+}
 
-	/* Check if the RI key value of the tuple is invalid */
-	for (int i = 0; i < original_data->ncols; i++)
+/*
+ * Check if any of the key columns have NULL values.
+ */
+static bool
+has_null_key_values(LogicalRepTupleData *data, Bitmapset *indexkeys)
+{
+	for (int i = 0; i < data->ncols; i++)
 	{
-		if (!bms_is_member(i, relentry->remoterel.attkeys))
-			continue;
+		if (bms_is_member(i, indexkeys) &&
+			data->colstatus[i] == LOGICALREP_COLUMN_NULL)
+			return true;
+	}
 
-		/*
-		 * NULL in the new tuple means the replica identity key hasn't changed,
-		 * so no new dependency needs to be recorded. The dependency should have
-		 * been recorded when processing the old tuple.
-		 */
-		if (original_data->colstatus[i] == LOGICALREP_COLUMN_NULL)
-			return;
+	return false;
+}
+
+/*
+ * Build a hash key for replica_identity_table using the given relation and
+ * tuple data, restricted to the specified key columns.
+ */
+static ReplicaIdentityKey *
+build_replica_identity_key(Oid relid, LogicalRepTupleData *original_data,
+						   Bitmapset *keycols)
+{
+	LogicalRepTupleData *keydata;
+	ReplicaIdentityKey *key;
+	MemoryContext oldctx;
+	int		nkeycols = bms_num_members(keycols);
+
+	oldctx = MemoryContextSwitchTo(ApplyContext);
+
+	/* Allocate space for replica identity values */
+	keydata = palloc0_object(LogicalRepTupleData);
+
+	if (nkeycols)
+	{
+		keydata->colvalues = palloc0_array(StringInfoData, nkeycols);
+		keydata->colstatus = palloc0_array(char, nkeycols);
+	}
+
+	keydata->ncols = nkeycols;
+
+	for (int i = 0, i_key = 0; i < original_data->ncols; i++)
+	{
+		if (!bms_is_member(i, keycols))
+			continue;
 
 		/*
 		 * LOGICALREP_COLUMN_UNCHANGED only indicates that a TOAST column in the
@@ -1011,85 +1082,184 @@ check_and_record_ri_dependency(Oid relid, LogicalRepTupleData *original_data,
 		 */
 		Assert(original_data->colstatus[i] != LOGICALREP_COLUMN_UNCHANGED ||
 			   original_data->colvalues[i].len > 0);
+
+		if (original_data->colstatus[i] != LOGICALREP_COLUMN_NULL)
+		{
+			StringInfo	original_colvalue = &original_data->colvalues[i];
+
+			initStringInfoExt(&keydata->colvalues[i_key], original_colvalue->len + 1);
+			appendStringInfoString(&keydata->colvalues[i_key], original_colvalue->data);
+		}
+
+		keydata->colstatus[i_key] = original_data->colstatus[i];
+		i_key++;
 	}
 
-	oldctx = MemoryContextSwitchTo(ApplyContext);
+	key = palloc0_object(ReplicaIdentityKey);
+	key->relid = relid;
+	key->data = keydata;
 
-	/* Allocate space for replica identity values */
-	ridata = palloc0_object(LogicalRepTupleData);
-	ridata->colvalues = palloc0_array(StringInfoData, n_ri);
-	ridata->colstatus = palloc0_array(char, n_ri);
-	ridata->ncols = n_ri;
+	MemoryContextSwitchTo(oldctx);
 
-	for (int i_original = 0, i_ri = 0; i_original < original_data->ncols; i_original++)
-	{
-		StringInfo	original_colvalue = &original_data->colvalues[i_original];
+	return key;
+}
 
-		if (!bms_is_member(i_original, relentry->remoterel.attkeys))
-			continue;
+/*
+ * Mostly same as check_and_record_ri_dependency() but for local unique indexes.
+ *
+ * See the comments in applyparallelworker.c for details on why tracking these
+ * dependencies is necessary.
+ */
+static void
+check_and_record_local_key_dependency(Oid relid,
+									  LogicalRepTupleData *original_data,
+									  bool old_tuple,
+									  TransactionId new_depended_xid,
+									  List **depends_on_xids)
+{
+	LogicalRepRelMapEntry *relentry;
+	ReplicaIdentityKey *rikey;
 
-		initStringInfoExt(&ridata->colvalues[i_ri], original_colvalue->len + 1);
-		appendStringInfoString(&ridata->colvalues[i_ri], original_colvalue->data);
-		ridata->colstatus[i_ri] = original_data->colstatus[i_original];
-		i_ri++;
-	}
+	Assert(depends_on_xids);
 
-	rikey = palloc0_object(ReplicaIdentityKey);
-	rikey->relid = relid;
-	rikey->data = ridata;
+	/* Search for existing entry */
+	relentry = logicalrep_get_relentry(relid);
 
-	MemoryContextSwitchTo(oldctx);
+	Assert(relentry);
 
 	/*
-	 * The new xid could be invalid if the transaction will be applied by the
-	 * leader itself which means all the changes will be committed before
-	 * processing next transaction. In this case, we only need to check for
-	 * dependencies on preceding transactions, there is no need to record a new
-	 * dependency for subsequent transactions to wait on.
+	 * Gather information for local indexes if not yet. We require to be in a
+	 * transaction state because system catalogs are read.
 	 */
-	if (!TransactionIdIsValid(new_depended_xid))
+	if (!relentry->local_unique_indexes_collected)
 	{
-		rientry = replica_identity_lookup(replica_identity_table, rikey);
-		free_replica_identity_key(rikey);
+		bool		needs_start = !IsTransactionOrTransactionBlock();
 
-		if (rientry && has_active_key_dependency(rientry, true))
-		{
-			elog(DEBUG1, "found conflicting replica identity change on table %u from %u",
-				 relid, rientry->remote_xid);
+		if (needs_start)
+			StartTransactionCommand();
 
-			append_xid_dependency(rientry->remote_xid, depends_on_xids);
-		}
+		logicalrep_rel_load(NULL, relid, AccessShareLock);
 
-		return;
-	}
+		if (needs_start)
+			CommitTransactionCommand();
 
-	/* Record a new dependency for subsequent transactions to wait on */
-	rientry = replica_identity_insert(replica_identity_table, rikey,
-									  &found);
+		Assert(relentry->local_unique_indexes_collected);
+	}
 
-	/*
-	 * Release the key built to search the entry, if the entry already exists.
-	 */
-	if (found)
+	foreach_ptr(LogicalRepSubscriberIdx, idxinfo, relentry->local_unique_indexes)
 	{
-		free_replica_identity_key(rikey);
+		/*
+		 * NULL values in the new tuple represent true NULLs in a unique index.
+		 * If NULLs are treated as distinct (nulls_distinct = true), they never
+		 * cause conflicts. Therefore, we can skip dependency checking if any
+		 * key column is NULL in this case.
+		 *
+		 * However, for old tuples in UPDATE or DELETE operations, a NULL key
+		 * simply indicate the column lies outside the replica identity key
+		 * rather than a true NULL. In such cases, the remote old tuple could
+		 * still conflict with a local tuple, so we must not skip the check.
+		 */
+		if (!old_tuple && idxinfo->nulls_distinct &&
+			has_null_key_values(original_data, idxinfo->indexkeys))
+			continue;
 
 		/*
-		 * Append the dependency to the list if the current transaction was not
-		 * the lastest one to modify the key.
+		 * Old tuples of unique keys do not conflict with any preceding
+		 * transaction (see the comments in applyparallelworker.c for details on
+		 * conflicting cases). When we don't need to record a new dependency, we
+		 * can skip processing this index entirely.
 		 */
-		if (has_active_key_dependency(rientry, false) &&
-			!TransactionIdEquals(rientry->remote_xid, new_depended_xid))
+		if (old_tuple && !TransactionIdIsValid(new_depended_xid))
+			continue;
+
+		rikey = build_replica_identity_key(relid, original_data, idxinfo->indexkeys);
+		rikey->kind = LOGICALREP_KEY_LOCAL_UNIQUE;
+
+		/*
+		 * For old tuples, record a dependency for subsequent transactions to
+		 * wait on; no preceding transactions are added to the list.
+		 *
+		 * For new tuples in INSERT or UPDATE, check for existing key
+		 * dependencies and add any dependent transactions to the list.
+		 */
+		if (old_tuple)
+		{
+			(void) check_and_record_key_dependency(rikey, new_depended_xid);
+		}
+		else
 		{
-			elog(DEBUG1, "found conflicting replica identity change on table %u from %u",
-				 relid, rientry->remote_xid);
+			TransactionId	xid;
 
-			append_xid_dependency(rientry->remote_xid, depends_on_xids);
+			xid = check_and_record_key_dependency(rikey, InvalidTransactionId);
+
+			if (TransactionIdIsValid(xid) &&
+				!TransactionIdEquals(xid, new_depended_xid))
+				append_xid_dependency(xid, depends_on_xids);
 		}
 	}
+}
 
-	/* Update the new depended xid into the entry */
-	rientry->remote_xid = new_depended_xid;
+/*
+ * Check for dependencies on preceding transactions that modify the same key.
+ * Returns the dependent transactions in 'depends_on_xids'.
+ *
+ * Additionally, if new_depended_xid is valid, record it as a dependency for the
+ * replica identity key modification, allowing subsequent transactions that
+ * modify the same key to be dependent on it.
+ */
+static void
+check_and_record_ri_dependency(Oid relid, LogicalRepTupleData *original_data,
+							   TransactionId new_depended_xid,
+							   List **depends_on_xids)
+{
+	LogicalRepRelMapEntry *relentry;
+	ReplicaIdentityKey *rikey;
+	TransactionId xid;
+
+	Assert(depends_on_xids);
+
+	/* Search for existing entry */
+	relentry = logicalrep_get_relentry(relid);
+
+	Assert(relentry);
+
+	/*
+	 * First check whether any previous transaction (other than the current one)
+	 * has affected the whole table e.g., truncate or schema change from
+	 * publisher.
+	 */
+	if (has_active_rel_dependency(relentry) &&
+		!TransactionIdEquals(relentry->last_depended_xid, new_depended_xid))
+		append_xid_dependency(relentry->last_depended_xid, depends_on_xids);
+
+	/*
+	 * Return if there are no replica identity columns, indicating that the
+	 * remote relation has neither a replica identity key nor is marked as
+	 * replica identity full.
+	 */
+	if (!bms_num_members(relentry->remoterel.attkeys))
+		return;
+
+	/*
+	 * NULL in the new tuple means the replica identity key hasn't changed, so
+	 * no new dependency needs to be recorded. The dependency should have been
+	 * recorded when processing the old tuple.
+	 */
+	if (has_null_key_values(original_data, relentry->remoterel.attkeys))
+		return;
+
+	rikey = build_replica_identity_key(relid, original_data, relentry->remoterel.attkeys);
+	rikey->kind = LOGICALREP_KEY_REPLICA_IDENTITY;
+
+	xid = check_and_record_key_dependency(rikey, new_depended_xid);
+
+	/*
+	 * Append the dependency to the list if the current transaction was not the
+	 * lastest one to modify the key.
+	 */
+	if (TransactionIdIsValid(xid) &&
+		!TransactionIdEquals(xid, new_depended_xid))
+		append_xid_dependency(xid, depends_on_xids);
 }
 
 /*
@@ -1291,6 +1461,9 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			relid = logicalrep_read_insert(&change, &newtup);
 			check_and_record_ri_dependency(relid, &newtup, new_depended_xid,
 										   &depends_on_xids);
+			check_and_record_local_key_dependency(relid, &newtup, false,
+												  new_depended_xid,
+												  &depends_on_xids);
 			check_dependency_for_parallel_safety(relid, new_depended_xid,
 												 &depends_on_xids);
 			break;
@@ -1304,6 +1477,10 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 				check_and_record_ri_dependency(relid, &oldtup, new_depended_xid,
 											   &depends_on_xids);
 
+				check_and_record_local_key_dependency(relid, &oldtup, true,
+													  new_depended_xid,
+													  &depends_on_xids);
+
 				check_dependency_for_parallel_safety(relid, new_depended_xid,
 													 &depends_on_xids);
 
@@ -1325,6 +1502,9 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 
 			check_and_record_ri_dependency(relid, &newtup, new_depended_xid,
 										   &depends_on_xids);
+			check_and_record_local_key_dependency(relid, &newtup, false,
+												  new_depended_xid,
+												  &depends_on_xids);
 			check_dependency_for_parallel_safety(relid, new_depended_xid,
 												 &depends_on_xids);
 			break;
@@ -1333,6 +1513,9 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			relid = logicalrep_read_delete(&change, &oldtup);
 			check_and_record_ri_dependency(relid, &oldtup, new_depended_xid,
 										   &depends_on_xids);
+			check_and_record_local_key_dependency(relid, &oldtup, true,
+												  new_depended_xid,
+												  &depends_on_xids);
 			check_dependency_for_parallel_safety(relid, new_depended_xid,
 												 &depends_on_xids);
 			break;
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..31bbfcf1971 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -33,7 +33,6 @@
 #include "storage/procnumber.h"
 #include "utils/memutils.h"
 
-
 /*
  * One edge in the waits-for graph.
  *
diff --git a/src/include/replication/logicalrelation.h b/src/include/replication/logicalrelation.h
index b1079d3f2fa..fe38862b300 100644
--- a/src/include/replication/logicalrelation.h
+++ b/src/include/replication/logicalrelation.h
@@ -46,6 +46,10 @@ typedef struct LogicalRepRelMapEntry
 	 */
 	TransactionId last_depended_xid;
 
+	/* Local unique indexes. Used for dependency tracking */
+	List	   *local_unique_indexes;
+	bool		local_unique_indexes_collected;
+
 	/*
 	 * Whether the relation can be applied in parallel or not. It is
 	 * distinglish whether defined triggers are the immutable or not.
@@ -61,6 +65,14 @@ typedef struct LogicalRepRelMapEntry
 	char		parallel_safe;
 } LogicalRepRelMapEntry;
 
+
+typedef struct LogicalRepSubscriberIdx
+{
+	Oid			indexoid;	/* OID of the local key */
+	Bitmapset  *indexkeys;	/* Bitmap of key columns *on remote* */
+	bool		nulls_distinct;	/* Whether NULLs are considered distinct */
+} LogicalRepSubscriberIdx;
+
 extern void logicalrep_relmap_update(LogicalRepRelation *remoterel);
 extern void logicalrep_partmap_reset_relmap(LogicalRepRelation *remoterel);
 
diff --git a/src/test/subscription/t/050_parallel_apply.pl b/src/test/subscription/t/050_parallel_apply.pl
index 358c7b7b7e1..6b0d5c7e383 100644
--- a/src/test/subscription/t/050_parallel_apply.pl
+++ b/src/test/subscription/t/050_parallel_apply.pl
@@ -403,4 +403,128 @@ $result =
   $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab");
 is ($result, 5011, 'inserts are replicated to subscriber');
 
+##################################################
+# Test that the dependency tracking works correctly for local unique indexes on
+# subscriber during parallel apply.
+##################################################
+
+# Truncate the data for upcoming tests
+$node_publisher->safe_psql('postgres', "TRUNCATE TABLE regress_tab;");
+$node_publisher->wait_for_catchup('regress_sub');
+
+# Define an unique index on subscriber
+$node_subscriber->safe_psql('postgres',
+    "CREATE UNIQUE INDEX local_unique_idx ON regress_tab (value);");
+
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (1, 'would conflict');");
+
+$node_publisher->wait_for_catchup('regress_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab");
+is ($result, 1, 'the insert is replicated to subscriber');
+
+# Attach an injection_point. Parallel workers would wait before the commit
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+# Delete the tuple on publisher.
+$node_publisher->safe_psql('postgres',
+    "DELETE FROM regress_tab WHERE id = 1;");
+
+# Wait until the parallel worker enters the injection point.
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+$offset = -s $node_subscriber->logfile;
+
+# Insert tuples. This should conflict with the DELETE transaction, as both
+# transactions modify the same key. The parallel worker will wait for the
+# preceding transaction to finish.
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (2, 'would conflict');");
+
+# Verify the dependency is detected for the insert
+$str = $node_subscriber->wait_for_log(qr/found conflicting local unique key change on table [1-9][0-9]+ from ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /found conflicting local unique key change on table [1-9][0-9]+ from ([1-9][0-9]+)/;
+
+# Verify the parallel worker waits for the same transaction
+$node_subscriber->wait_for_log(qr/wait for depended xid $xid/, $offset);
+
+ok(1, "local unique key dependency detected for parallel apply");
+
+# Wakeup the parallel worker
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+# Verify the streamed transaction can be applied
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+
+$node_publisher->wait_for_catchup('regress_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab");
+is ($result, 1, 'inserts are replicated to subscriber');
+
+# Test that the dependency tracking works correctly for local unique indexes on
+# subscriber during parallel apply when the unique index has expression.
+$node_subscriber->safe_psql('postgres', "DROP INDEX local_unique_idx;");
+$node_subscriber->safe_psql('postgres',
+    "CREATE UNIQUE INDEX local_unique_idx_expr ON regress_tab ((LOWER(value)));");
+
+# Attach an injection_point. Parallel workers would wait before the commit
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+# Insert a tuple on publisher. Parallel worker would wait at the injection
+# point
+$node_publisher->safe_psql('postgres',
+    "DELETE FROM regress_tab WHERE id = 2;");
+
+# Wait until the parallel worker enters the injection point.
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+$offset = -s $node_subscriber->logfile;
+
+# Insert tuples. This should conflict with the DELETE transaction, as both
+# transactions modify the same key value. The parallel worker will wait for the
+# preceding transaction to finish.
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (3, 'WOULD CONFLICT');");
+
+# Verify the dependency is detected for the insert
+$str = $node_subscriber->wait_for_log(qr/found conflicting local unique key change on table [1-9][0-9]+ from ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /found conflicting local unique key change on table [1-9][0-9]+ from ([1-9][0-9]+)/;
+
+# Verify the parallel worker waits for the same transaction
+$node_subscriber->wait_for_log(qr/wait for depended xid $xid/, $offset);
+
+ok(1, "local unique key dependency from index expression detected for parallel apply");
+
+# Wakeup the parallel worker
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+# Verify the streamed transaction can be applied
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+
+$node_publisher->wait_for_catchup('regress_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab");
+is ($result, 1, 'inserts are replicated to subscriber');
+
+# Cleanup
+$node_subscriber->safe_psql('postgres', "DROP INDEX local_unique_idx_expr;");
+$node_publisher->safe_psql('postgres', "TRUNCATE TABLE regress_tab;");
+$node_publisher->wait_for_catchup('regress_sub');
+
 done_testing();
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index db8bb4e1e43..34c9592e3e5 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -1701,6 +1701,7 @@ LogicalRepBeginData
 LogicalRepCommitData
 LogicalRepCommitPreparedTxnData
 LogicalRepCtxStruct
+LogicalRepKeyKind
 LogicalRepMsgType
 LogicalRepPartMapEntry
 LogicalRepPreparedTxnData
@@ -1710,6 +1711,7 @@ LogicalRepRelation
 LogicalRepRollbackPreparedTxnData
 LogicalRepSequenceInfo
 LogicalRepStreamAbortData
+LogicalRepSubscriberIdx
 LogicalRepTupleData
 LogicalRepTyp
 LogicalRepWorker
-- 
2.43.0



  [application/octet-stream] v20-0007-Wait-applying-transaction-if-one-of-user-defined.patch (11.9K, ../../TY4PR01MB1771875A7A64B139EF8C8D05C94E62@TY4PR01MB17718.jpnprd01.prod.outlook.com/5-v20-0007-Wait-applying-transaction-if-one-of-user-defined.patch)
  download | inline diff:
From 341d41d359f89d894cd4e2278e7d4fd5054c3f5b Mon Sep 17 00:00:00 2001
From: Zhijie Hou <[email protected]>
Date: Tue, 9 Jun 2026 18:45:02 +0800
Subject: [PATCH v20 7/9] Wait applying transaction if one of user-defined
 triggers is immutable

Since many parallel workers apply transactions, triggers for relations can also
be fired in parallel, which may obtain unexpected results. To make it safe,
parallel apply workers wait for the previously dispatched transaction before
applying changes to the relation that has mutable triggers.
---
 src/backend/replication/logical/relation.c | 123 ++++++++++++++++++---
 src/backend/replication/logical/worker.c   |  65 +++++++++++
 src/include/replication/logicalrelation.h  |  20 ++++
 3 files changed, 194 insertions(+), 14 deletions(-)

diff --git a/src/backend/replication/logical/relation.c b/src/backend/replication/logical/relation.c
index e1ce183dfd3..eeb85f8cc5d 100644
--- a/src/backend/replication/logical/relation.c
+++ b/src/backend/replication/logical/relation.c
@@ -21,7 +21,9 @@
 #include "access/genam.h"
 #include "access/table.h"
 #include "catalog/namespace.h"
+#include "catalog/pg_proc.h"
 #include "catalog/pg_subscription_rel.h"
+#include "commands/trigger.h"
 #include "executor/executor.h"
 #include "nodes/makefuncs.h"
 #include "replication/logicalrelation.h"
@@ -160,6 +162,10 @@ logicalrep_relmap_free_entry(LogicalRepRelMapEntry *entry)
  *
  * Called when new relation mapping is sent by the publisher to update
  * our expected view of incoming data from said publisher.
+ *
+ * Note that we do not check the user-defined constraints here. PostgreSQL has
+ * already assumed that CHECK constraints' conditions are immutable and here
+ * follows the rule.
  */
 void
 logicalrep_relmap_update(LogicalRepRelation *remoterel)
@@ -209,6 +215,8 @@ logicalrep_relmap_update(LogicalRepRelation *remoterel)
 		(remoterel->relkind == 0) ? RELKIND_RELATION : remoterel->relkind;
 
 	entry->remoterel.attkeys = bms_copy(remoterel->attkeys);
+
+	entry->parallel_safe = LOGICALREP_PARALLEL_UNKNOWN;
 	MemoryContextSwitchTo(oldctx);
 }
 
@@ -354,27 +362,79 @@ logicalrep_rel_mark_updatable(LogicalRepRelMapEntry *entry)
 }
 
 /*
- * Open the local relation associated with the remote one.
+ * Check all local triggers for the relation to see the parallelizability.
  *
- * Rebuilds the Relcache mapping if it was invalidated by local DDL.
+ * We regard relations as applicable in parallel if all triggers are immutable.
+ * Result is directly set to LogicalRepRelMapEntry::parallel_safe.
  */
-LogicalRepRelMapEntry *
-logicalrep_rel_open(LogicalRepRelId remoteid, LOCKMODE lockmode)
+static void
+check_defined_triggers(LogicalRepRelMapEntry *entry)
+{
+	TriggerDesc *trigdesc = entry->localrel->trigdesc;
+
+	/* Quick exit if triffer is not defined */
+	if (trigdesc == NULL)
+	{
+		entry->parallel_safe = LOGICALREP_PARALLEL_SAFE;
+		return;
+	}
+
+	/* Seek triggers one by one to see the volatility */
+	for (int i = 0; i < trigdesc->numtriggers; i++)
+	{
+		Trigger    *trigger = &trigdesc->triggers[i];
+
+		Assert(OidIsValid(trigger->tgfoid));
+
+		/* Skip if the trigger is not enabled for logical replication */
+		if (trigger->tgenabled == TRIGGER_DISABLED ||
+			trigger->tgenabled == TRIGGER_FIRES_ON_ORIGIN)
+			continue;
+
+		/* Check the volatility of the trigger. Exit if it is not immutable */
+		if (func_volatile(trigger->tgfoid) != PROVOLATILE_IMMUTABLE)
+		{
+			entry->parallel_safe = LOGICALREP_PARALLEL_RESTRICTED;
+			return;
+		}
+	}
+
+	/* All triggers are immutable, set as parallel safe */
+	entry->parallel_safe = LOGICALREP_PARALLEL_SAFE;
+}
+
+/*
+ * Actual workhorse for logicalrep_rel_open().
+ *
+ * Caller must specify *either* entry or key. If the entry is specified, its
+ * attributes are filled and returned. The logical relation is kept opening.
+ * If the key is given, the corresponding entry is first searched in the hash
+ * table and processed as in the above case. At the end, logical replication is
+ * closed.
+  */
+void
+logicalrep_rel_load(LogicalRepRelMapEntry *entry, LogicalRepRelId remoteid,
+					LOCKMODE lockmode)
 {
-	LogicalRepRelMapEntry *entry;
-	bool		found;
 	LogicalRepRelation *remoterel;
 
-	if (LogicalRepRelMap == NULL)
-		logicalrep_relmap_init();
+	Assert((entry && !remoteid) || (!entry && remoteid));
 
-	/* Search for existing entry. */
-	entry = hash_search(LogicalRepRelMap, &remoteid,
-						HASH_FIND, &found);
+	if (!entry)
+	{
+		bool		found;
 
-	if (!found)
-		elog(ERROR, "no relation map entry for remote relation ID %u",
-			 remoteid);
+		if (LogicalRepRelMap == NULL)
+			logicalrep_relmap_init();
+
+		/* Search for existing entry. */
+		entry = hash_search(LogicalRepRelMap, &remoteid,
+							HASH_FIND, &found);
+
+		if (!found)
+			elog(ERROR, "no relation map entry for remote relation ID %u",
+				 remoteid);
+	}
 
 	remoterel = &entry->remoterel;
 
@@ -500,6 +560,13 @@ logicalrep_rel_open(LogicalRepRelId remoteid, LOCKMODE lockmode)
 		entry->localindexoid = FindLogicalRepLocalIndex(entry->localrel, remoterel,
 														entry->attrmap);
 
+		/*
+		 * Leader must also collect all local unique indexes for dependency
+		 * tracking.
+		 */
+		if (am_leader_apply_worker())
+			check_defined_triggers(entry);
+
 		entry->localrelvalid = true;
 	}
 
@@ -508,6 +575,34 @@ logicalrep_rel_open(LogicalRepRelId remoteid, LOCKMODE lockmode)
 											   entry->localreloid,
 											   &entry->statelsn);
 
+	if (remoteid)
+		logicalrep_rel_close(entry, lockmode);
+}
+
+/*
+ * Open the local relation associated with the remote one.
+ *
+ * Rebuilds the Relcache mapping if it was invalidated by local DDL.
+ */
+LogicalRepRelMapEntry *
+logicalrep_rel_open(LogicalRepRelId remoteid, LOCKMODE lockmode)
+{
+	LogicalRepRelMapEntry *entry;
+	bool		found;
+
+	if (LogicalRepRelMap == NULL)
+		logicalrep_relmap_init();
+
+	/* Search for existing entry. */
+	entry = hash_search(LogicalRepRelMap, &remoteid,
+						HASH_FIND, &found);
+
+	if (!found)
+		elog(ERROR, "no relation map entry for remote relation ID %u",
+			 remoteid);
+
+	logicalrep_rel_load(entry, 0, lockmode);
+
 	return entry;
 }
 
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 6595e0cdeee..717cf74f0ac 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -1173,6 +1173,57 @@ check_and_record_rel_dependency(LogicalRepRelId relid,
 		relentry->last_depended_xid = new_depended_xid;
 }
 
+/*
+ * Check the parallelizability of applying changes for the relation.
+ * Append the lastly dispatched transaction in in 'depends_on_xids' if the
+ * relation is parallel unsafe.
+ */
+static void
+check_dependency_for_parallel_safety(LogicalRepRelId relid,
+									 TransactionId new_depended_xid,
+									 List **depends_on_xids)
+{
+	LogicalRepRelMapEntry *relentry;
+
+	/* Quick exit if no transactions have been dispatched */
+	if (!TransactionIdIsValid(last_parallelized_remote_xid))
+		return;
+
+	relentry = logicalrep_get_relentry(relid);
+
+	/*
+	 * Gather information for local triggres if not yet. We require to be in a
+	 * transaction state because system catalogs are read.
+	 */
+	if (relentry->parallel_safe == LOGICALREP_PARALLEL_UNKNOWN)
+	{
+		bool		needs_start = !IsTransactionOrTransactionBlock();
+
+		if (needs_start)
+			StartTransactionCommand();
+
+		logicalrep_rel_load(NULL, relid, AccessShareLock);
+
+		/*
+		 * Close the transaction if we start here. We must not abort because
+		 * it would release all session-level locks, such as the stream lock,
+		 * and break the deadlock detection mechanism between LA and PA. The
+		 * outcome is the same regardless of the end status, since the
+		 * transaction did not modify any tuples.
+		 */
+		if (needs_start)
+			CommitTransactionCommand();
+
+		Assert(relentry->parallel_safe != LOGICALREP_PARALLEL_UNKNOWN);
+	}
+
+	/* Do nothing for parallel safe relations */
+	if (relentry->parallel_safe == LOGICALREP_PARALLEL_SAFE)
+		return;
+
+	append_xid_dependency(last_parallelized_remote_xid, depends_on_xids);
+}
+
 /*
  * Check dependencies related to the current change by determining if the
  * modification impacts the same row or table as another ongoing transaction.
@@ -1240,6 +1291,8 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			relid = logicalrep_read_insert(&change, &newtup);
 			check_and_record_ri_dependency(relid, &newtup, new_depended_xid,
 										   &depends_on_xids);
+			check_dependency_for_parallel_safety(relid, new_depended_xid,
+												 &depends_on_xids);
 			break;
 
 		case LOGICAL_REP_MSG_UPDATE:
@@ -1251,6 +1304,9 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 				check_and_record_ri_dependency(relid, &oldtup, new_depended_xid,
 											   &depends_on_xids);
 
+				check_dependency_for_parallel_safety(relid, new_depended_xid,
+													 &depends_on_xids);
+
 				/*
 				 * Copy unchanged column values from the old tuple to the new
 				 * tuple for replica identity dependency checking. See
@@ -1269,12 +1325,16 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 
 			check_and_record_ri_dependency(relid, &newtup, new_depended_xid,
 										   &depends_on_xids);
+			check_dependency_for_parallel_safety(relid, new_depended_xid,
+												 &depends_on_xids);
 			break;
 
 		case LOGICAL_REP_MSG_DELETE:
 			relid = logicalrep_read_delete(&change, &oldtup);
 			check_and_record_ri_dependency(relid, &oldtup, new_depended_xid,
 										   &depends_on_xids);
+			check_dependency_for_parallel_safety(relid, new_depended_xid,
+												 &depends_on_xids);
 			break;
 
 		case LOGICAL_REP_MSG_TRUNCATE:
@@ -1287,9 +1347,14 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			 * modified the same table.
 			 */
 			foreach_int(truncated_relid, remote_relids)
+			{
 				check_and_record_rel_dependency(truncated_relid,
 												new_depended_xid,
 												&depends_on_xids);
+				check_dependency_for_parallel_safety(truncated_relid,
+													 new_depended_xid,
+													 &depends_on_xids);
+			}
 
 			break;
 
diff --git a/src/include/replication/logicalrelation.h b/src/include/replication/logicalrelation.h
index cd852337165..b1079d3f2fa 100644
--- a/src/include/replication/logicalrelation.h
+++ b/src/include/replication/logicalrelation.h
@@ -45,6 +45,20 @@ typedef struct LogicalRepRelMapEntry
 	 * applying (see check_dependency_on_rel).
 	 */
 	TransactionId last_depended_xid;
+
+	/*
+	 * Whether the relation can be applied in parallel or not. It is
+	 * distinglish whether defined triggers are the immutable or not.
+	 *
+	 * Theoretically, we can determine the parallelizability for each type of
+	 * replication messages, INSERT/UPDATE/DELETE/TRUNCATE. But it is not done
+	 * yet to reduce the number of attributes.
+	 *
+	 * Note that we do not check the user-defined constraints here. PostgreSQL
+	 * has already assumed that CHECK constraints' conditions are immutable and
+	 * here follows the rule.
+	 */
+	char		parallel_safe;
 } LogicalRepRelMapEntry;
 
 extern void logicalrep_relmap_update(LogicalRepRelation *remoterel);
@@ -52,6 +66,8 @@ extern void logicalrep_partmap_reset_relmap(LogicalRepRelation *remoterel);
 
 extern LogicalRepRelMapEntry *logicalrep_rel_open(LogicalRepRelId remoteid,
 												  LOCKMODE lockmode);
+extern void logicalrep_rel_load(LogicalRepRelMapEntry *entry,
+								LogicalRepRelId remoteid, LOCKMODE lockmode);
 extern LogicalRepRelMapEntry *logicalrep_partition_open(LogicalRepRelMapEntry *root,
 														Relation partrel, AttrMap *map);
 extern void logicalrep_rel_close(LogicalRepRelMapEntry *rel,
@@ -62,4 +78,8 @@ extern int	logicalrep_get_num_rels(void);
 extern void logicalrep_write_all_rels(StringInfo out);
 extern LogicalRepRelMapEntry *logicalrep_get_relentry(LogicalRepRelId remoteid);
 
+#define LOGICALREP_PARALLEL_SAFE		's'
+#define LOGICALREP_PARALLEL_RESTRICTED	'r'
+#define LOGICALREP_PARALLEL_UNKNOWN		'u'
+
 #endif							/* LOGICALRELATION_H */
-- 
2.43.0



  [application/octet-stream] v20-0006-Track-dependencies-for-streamed-transactions.patch (10.4K, ../../TY4PR01MB1771875A7A64B139EF8C8D05C94E62@TY4PR01MB17718.jpnprd01.prod.outlook.com/6-v20-0006-Track-dependencies-for-streamed-transactions.patch)
  download | inline diff:
From 5fc2bddec6568c73283bf7628ca1fb6bd33e7f04 Mon Sep 17 00:00:00 2001
From: Zhijie Hou <[email protected]>
Date: Tue, 9 Jun 2026 18:42:48 +0800
Subject: [PATCH v20 6/9] Track dependencies for streamed transactions

This commit allows tracking dependencies of streamed transactions.

Regarding the streaming=on case, dependency tracking is enabled while applying
spooled changes from files.

In the streaming=parallel case, dependency tracking is performed when the leader
sends changes to parallel workers. Apart from non-streamed transactions, the
leader waits for parallel workers till the assigned transactions are finished at
COMMIT/PREPARE/ABORT; thus, the XID of streamed transactions is not cached as
the lastly handled one. Also, streamed transactions are not recorded as
parallelized transactions because upcoming workers do not have to wait for them.
---
 .../replication/logical/applyparallelworker.c |  8 ++-
 src/backend/replication/logical/worker.c      | 62 ++++++++++++++----
 src/include/replication/worker_internal.h     |  1 -
 src/test/subscription/t/050_parallel_apply.pl | 63 +++++++++++++++++++
 4 files changed, 120 insertions(+), 14 deletions(-)

diff --git a/src/backend/replication/logical/applyparallelworker.c b/src/backend/replication/logical/applyparallelworker.c
index dc9ae87e88a..58bd7a69992 100644
--- a/src/backend/replication/logical/applyparallelworker.c
+++ b/src/backend/replication/logical/applyparallelworker.c
@@ -165,7 +165,9 @@
  * key) as another ongoing transaction (see handle_dependency_on_change for
  * details). If so, the leader sends a list of dependent transaction IDs to the
  * parallel worker, indicating that the parallel apply worker must wait for
- * these transactions to commit before proceeding.
+ * these transactions to commit before proceeding. If transactions are streamed
+ * but leader deciedes not to assign parallel apply workers, dependencies are
+ * verified when the transaction is committed.
  *
  * Tracking dependencies is necessary even when commit order is preserved.
  * Consider two transactions: TX-1 (INSERT row 1) and TX-2 (DELETE row 1). If
@@ -1864,6 +1866,10 @@ pa_stream_abort(LogicalRepStreamAbortData *abort_data)
 	TransactionId xid = abort_data->xid;
 	TransactionId subxid = abort_data->subxid;
 
+	/* Streamed transactions won't be registered */
+	Assert(!dshash_find(parallelized_txns, &xid, false) &&
+		   !dshash_find(parallelized_txns, &subxid, false));
+
 	/*
 	 * Update origin state so we can restart streaming from correct position
 	 * in case of crash.
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index ea4378fb1f2..6595e0cdeee 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -1538,13 +1538,22 @@ handle_streamed_transaction(LogicalRepMsgType action, StringInfo s)
 		case TRANS_LEADER_SEND_TO_PARALLEL:
 			Assert(winfo);
 
+			/*
+			 * Pass InvalidTransactionId to skip dependency recording for this
+			 * change. Streaming transactions are assumed not to conflict with
+			 * other transactions, so subsequent transactions do not need to
+			 * wait for them to finish.
+			 */
+			handle_dependency_on_change(action, s, InvalidTransactionId, winfo);
+
 			/*
 			 * XXX The publisher side doesn't always send relation/type update
 			 * messages after the streaming transaction, so also update the
 			 * relation/type in leader apply worker. See function
 			 * cleanup_rel_sync_cache.
 			 */
-			if (pa_send_data(winfo, s->len, s->data))
+			if (!winfo->serialize_changes &&
+				pa_send_data(winfo, s->len, s->data))
 				return (action != LOGICAL_REP_MSG_RELATION &&
 						action != LOGICAL_REP_MSG_TYPE);
 
@@ -2675,6 +2684,13 @@ apply_handle_stream_prepare(StringInfo s)
 			apply_spooled_messages(MyLogicalRepWorker->stream_fileset,
 								   prepare_data.xid, prepare_data.prepare_lsn);
 
+			/* Wait until the last transaction finishes */
+			if (TransactionIdIsValid(last_parallelized_remote_xid))
+			{
+				pa_wait_for_depended_transaction(last_parallelized_remote_xid);
+				last_parallelized_remote_xid = InvalidTransactionId;
+			}
+
 			/* Mark the transaction as prepared. */
 			apply_handle_prepare_internal(&prepare_data);
 
@@ -2698,6 +2714,12 @@ apply_handle_stream_prepare(StringInfo s)
 		case TRANS_LEADER_SEND_TO_PARALLEL:
 			Assert(winfo);
 
+			/*
+			 * Build a dependency between this transaction and the lastly
+			 * committed transaction to preserve the commit order.
+			 */
+			build_dependency_with_last_committed_txn(winfo);
+
 			if (pa_send_data(winfo, s->len, s->data))
 			{
 				/* Finish processing the streaming transaction. */
@@ -2764,6 +2786,11 @@ apply_handle_stream_prepare(StringInfo s)
 
 	pgstat_report_stat(false);
 
+	/*
+	 * No need to update the last_parallelized_remote_xid here because leader
+	 * worker always wait until streamed transactions finish.
+	 */
+
 	/*
 	 * Process any tables that are being synchronized in parallel, as well as
 	 * any newly added tables or sequences.
@@ -2877,17 +2904,8 @@ apply_handle_stream_start(StringInfo s)
 
 	set_apply_error_context_xact(stream_xid, InvalidXLogRecPtr);
 
-	/*
-	 * Try to allocate a worker for the streaming transaction.
-	 *
-	 * TODO: Support assigning streaming transactions to parallel apply workers
-	 * even when non-streaming transactions are running and better dependency
-	 * handling between streaming and non-streaming transactions.
-	 */
-	if (first_segment &&
-		am_leader_apply_worker() &&
-		(!TransactionIdIsValid(last_parallelized_remote_xid) ||
-		 pa_transaction_committed(last_parallelized_remote_xid)))
+	/* Try to allocate a worker for the streaming transaction. */
+	if (first_segment)
 		pa_allocate_worker(stream_xid, true);
 
 	apply_action = get_transaction_apply_action(stream_xid, &winfo);
@@ -3551,6 +3569,13 @@ apply_handle_stream_commit(StringInfo s)
 			apply_spooled_messages(MyLogicalRepWorker->stream_fileset, xid,
 								   commit_data.commit_lsn);
 
+			/* Wait until the last transaction finishes */
+			if (TransactionIdIsValid(last_parallelized_remote_xid))
+			{
+				pa_wait_for_depended_transaction(last_parallelized_remote_xid);
+				last_parallelized_remote_xid = InvalidTransactionId;
+			}
+
 			apply_handle_commit_internal(&commit_data);
 
 			/* Unlink the files with serialized changes and subxact info. */
@@ -3562,6 +3587,19 @@ apply_handle_stream_commit(StringInfo s)
 		case TRANS_LEADER_SEND_TO_PARALLEL:
 			Assert(winfo);
 
+			/*
+			 * Apart from non-streaming case, no need to mark this transaction
+			 * as parallelized. Because the leader waits until the streamed
+			 * transaction is committed thus commit ordering is always
+			 * preserved.
+			 */
+
+			/*
+			 * Build a dependency between this transaction and the lastly
+			 * committed transaction to preserve the commit order.
+			 */
+			build_dependency_with_last_committed_txn(winfo);
+
 			if (pa_send_data(winfo, s->len, s->data))
 			{
 				/* Finish processing the streaming transaction. */
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index 02a165ccc0d..3dde899f465 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -370,7 +370,6 @@ extern void pa_switch_to_partial_serialize(ParallelApplyWorkerInfo *winfo,
 extern void pa_set_xact_state(ParallelApplyWorkerShared *wshared,
 							  ParallelTransState xact_state);
 extern void pa_set_stream_apply_worker(ParallelApplyWorkerInfo *winfo);
-
 extern void pa_start_subtrans(TransactionId current_xid,
 							  TransactionId top_xid);
 extern void pa_reset_subtrans(void);
diff --git a/src/test/subscription/t/050_parallel_apply.pl b/src/test/subscription/t/050_parallel_apply.pl
index 0494afa418c..358c7b7b7e1 100644
--- a/src/test/subscription/t/050_parallel_apply.pl
+++ b/src/test/subscription/t/050_parallel_apply.pl
@@ -340,4 +340,67 @@ $result =
   $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab");
 is ($result, 20, 'inserts are replicated to subscriber');
 
+##################################################
+# Test that streaming transactions respect commit order preservation when
+# non-streaming transactions are being applied in parallel workers.
+##################################################
+
+$node_publisher->append_conf('postgresql.conf',
+   "logical_decoding_work_mem = 64kB");
+$node_publisher->reload;
+
+# Truncate the data for upcoming tests
+$node_publisher->safe_psql('postgres', "TRUNCATE TABLE regress_tab;");
+$node_publisher->wait_for_catchup('regress_sub');
+
+# Attach the injection_point again
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(71, 80), 'test');");
+
+# Wait until the parallel worker enters the injection point.
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+# Run a transaction which would be streamed
+my $h = $node_publisher->background_psql('postgres', on_error_stop => 0);
+
+$offset = -s $node_subscriber->logfile;
+
+$h->query_safe(
+	q{
+BEGIN;
+UPDATE regress_tab SET value = 'streamed-updated' WHERE id BETWEEN 71 AND 80;
+INSERT INTO regress_tab VALUES (generate_series(100, 5100), 'streamed');
+});
+
+# Verify the dependency is detected for the delete
+$str = $node_subscriber->wait_for_log(qr/found conflicting replica identity change on table [1-9][0-9]+ from ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /found conflicting replica identity change on table [1-9][0-9]+ from ([1-9][0-9]+)/;
+
+# Verify the parallel worker waits for the same transaction
+$node_subscriber->wait_for_log(qr/wait for depended xid $xid/, $offset);
+
+ok(1, "replica identity dependency from streamed txn detected for parallel apply");
+
+# Wakeup the parallel worker
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+# Verify the streamed transaction can be applied
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+
+$h->query_safe("COMMIT;");
+
+$node_publisher->wait_for_catchup('regress_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab");
+is ($result, 5011, 'inserts are replicated to subscriber');
+
 done_testing();
-- 
2.43.0



  [application/octet-stream] v20-0005-support-2PC.patch (13.8K, ../../TY4PR01MB1771875A7A64B139EF8C8D05C94E62@TY4PR01MB17718.jpnprd01.prod.outlook.com/7-v20-0005-support-2PC.patch)
  download | inline diff:
From 6ae2bb7ed79a3a4b4447e75dc3657c1879f606c0 Mon Sep 17 00:00:00 2001
From: Zhijie Hou <[email protected]>
Date: Tue, 9 Jun 2026 17:36:33 +0800
Subject: [PATCH v20 5/9] support 2PC

This patch allows the PREPARE transaction to be applied in parallel. Parallel
apply workers are assigned to a transaction when BEGIN_PREPARE is received. This
part and the dependency-waiting mechanism are the same as a normal transaction.

A parallel worker can be freed after it handles a PREPARE message. The prepared
transaction can be deleted from parallelized_txns at that time; the upcoming
transactions will wait until then.

The leader apply worker resolves COMMIT PREPARED/ROLLBACK PREPARED. Since it can
be serialized automatically, it does not add the transaction to parallelized_txns.
---
 src/backend/replication/logical/worker.c      | 218 +++++++++++++++---
 src/test/subscription/t/050_parallel_apply.pl |  74 +++++-
 2 files changed, 263 insertions(+), 29 deletions(-)

diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index d9be0e360db..ea4378fb1f2 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -807,6 +807,14 @@ delete_replica_identity_entries_for_txns(List *committed_xids)
 	if (!committed_xids)
 		return;
 
+	/*
+	 * Skip if the replica_identity_table is not initialized yet. This can
+	 * happen if the empty transaction was replicated and a parallel apply
+	 * worker was launched. See comments in apply_handle_prepare().
+	 */
+	if (!replica_identity_table)
+		return;
+
 	replica_identity_start_iterate(replica_identity_table, &i);
 	while ((rientry = replica_identity_iterate(replica_identity_table, &i)) != NULL)
 	{
@@ -2224,6 +2232,8 @@ static void
 apply_handle_begin_prepare(StringInfo s)
 {
 	LogicalRepPreparedTxnData begin_data;
+	ParallelApplyWorkerInfo *winfo;
+	TransApplyAction apply_action;
 
 	/* Tablesync should never receive prepare. */
 	if (am_tablesync_worker())
@@ -2235,12 +2245,54 @@ apply_handle_begin_prepare(StringInfo s)
 	Assert(!TransactionIdIsValid(stream_xid));
 
 	logicalrep_read_begin_prepare(s, &begin_data);
-	set_apply_error_context_xact(begin_data.xid, begin_data.prepare_lsn);
+
+	remote_xid = begin_data.xid;
+
+	set_apply_error_context_xact(remote_xid, begin_data.prepare_lsn);
 
 	remote_final_lsn = begin_data.prepare_lsn;
 
 	maybe_start_skipping_changes(begin_data.prepare_lsn);
 
+	pa_allocate_worker(remote_xid, false);
+
+	apply_action = get_transaction_apply_action(remote_xid, &winfo);
+
+	elog(DEBUG1, "new remote_xid %u", remote_xid);
+	switch (apply_action)
+	{
+		case TRANS_LEADER_APPLY:
+			break;
+
+		case TRANS_LEADER_SEND_TO_PARALLEL:
+			Assert(winfo);
+
+			if (pa_send_data(winfo, s->len, s->data))
+			{
+				pa_set_stream_apply_worker(winfo);
+				break;
+			}
+
+			/*
+			 * TODO: Support switching to PARTIAL_SERIALIZE mode when the send
+			 * buffer becomes full.
+			 */
+			ereport(ERROR,
+					errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+					errmsg("could not send data to the logical replication parallel apply worker"));
+			break;
+
+		case TRANS_PARALLEL_APPLY:
+			/* Hold the lock until the end of the transaction. */
+			pa_lock_transaction(MyParallelShared->xid, AccessExclusiveLock);
+			pa_set_xact_state(MyParallelShared, PARALLEL_TRANS_STARTED);
+			break;
+
+		default:
+			elog(ERROR, "unexpected apply action: %d", (int) apply_action);
+			break;
+	}
+
 	in_remote_transaction = true;
 
 	pgstat_report_activity(STATE_RUNNING, NULL);
@@ -2290,6 +2342,8 @@ static void
 apply_handle_prepare(StringInfo s)
 {
 	LogicalRepPreparedTxnData prepare_data;
+	ParallelApplyWorkerInfo *winfo;
+	TransApplyAction apply_action;
 
 	logicalrep_read_prepare(s, &prepare_data);
 
@@ -2300,36 +2354,131 @@ apply_handle_prepare(StringInfo s)
 								 LSN_FORMAT_ARGS(prepare_data.prepare_lsn),
 								 LSN_FORMAT_ARGS(remote_final_lsn))));
 
-	/*
-	 * Unlike commit, here, we always prepare the transaction even though no
-	 * change has happened in this transaction or all changes are skipped. It
-	 * is done this way because at commit prepared time, we won't know whether
-	 * we have skipped preparing a transaction because of those reasons.
-	 *
-	 * XXX, We can optimize such that at commit prepared time, we first check
-	 * whether we have prepared the transaction or not but that doesn't seem
-	 * worthwhile because such cases shouldn't be common.
-	 */
-	begin_replication_step();
+	apply_action = get_transaction_apply_action(remote_xid, &winfo);
 
-	apply_handle_prepare_internal(&prepare_data);
+	switch (apply_action)
+	{
+		case TRANS_LEADER_APPLY:
 
-	end_replication_step();
-	CommitTransactionCommand();
-	pgstat_report_stat(false);
+			/*
+			 * Unlike commit, here, we always prepare the transaction even
+			 * though no change has happened in this transaction or all
+			 * changes are skipped. It is done this way because at commit
+			 * prepared time, we won't know whether we have skipped preparing
+			 * a transaction because of those reasons.
+			 *
+			 * XXX, We can optimize such that at commit prepared time, we
+			 * first check whether we have prepared the transaction or not but
+			 * that doesn't seem worthwhile because such cases shouldn't be
+			 * common.
+			 */
+			begin_replication_step();
 
-	/*
-	 * It is okay not to set the local_end LSN for the prepare because we
-	 * always flush the prepare record. So, we can send the acknowledgment of
-	 * the remote_end LSN as soon as prepare is finished.
-	 *
-	 * XXX For the sake of consistency with commit, we could have set it with
-	 * the LSN of prepare but as of now we don't track that value similar to
-	 * XactLastCommitEnd, and adding it for this purpose doesn't seems worth
-	 * it.
-	 */
-	store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr,
-						 InvalidTransactionId);
+			/* Wait until the last transaction finishes */
+			if (TransactionIdIsValid(last_parallelized_remote_xid))
+			{
+				pa_wait_for_depended_transaction(last_parallelized_remote_xid);
+				last_parallelized_remote_xid = InvalidTransactionId;
+			}
+
+			apply_handle_prepare_internal(&prepare_data);
+
+			end_replication_step();
+			CommitTransactionCommand();
+			pgstat_report_stat(false);
+
+			/*
+			 * It is okay not to set the local_end LSN for the prepare because
+			 * we always flush the prepare record. So, we can send the
+			 * acknowledgment of the remote_end LSN as soon as prepare is
+			 * finished.
+			 *
+			 * XXX For the sake of consistency with commit, we could have set
+			 * it with the LSN of prepare but as of now we don't track that
+			 * value similar to XactLastCommitEnd, and adding it for this
+			 * purpose doesn't seems worth it.
+			 */
+			store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr,
+								 InvalidTransactionId);
+
+			break;
+
+		case TRANS_LEADER_SEND_TO_PARALLEL:
+			Assert(winfo);
+
+			/*
+			 * Mark this transaction as parallelized. This ensures that
+			 * upcoming transactions wait until this transaction is committed.
+			 */
+			pa_add_parallelized_transaction(remote_xid);
+
+			/*
+			 * Build a dependency between this transaction and the lastly
+			 * committed transaction to preserve the commit order.
+			 */
+			build_dependency_with_last_committed_txn(winfo);
+
+			if (pa_send_data(winfo, s->len, s->data))
+			{
+				/* Cache the remote_xid */
+				last_parallelized_remote_xid = remote_xid;
+
+				/* Finish processing the transaction. */
+				pa_xact_finish(winfo, prepare_data.end_lsn);
+				break;
+			}
+
+			/*
+			 * TODO: Support switching to PARTIAL_SERIALIZE mode when the send
+			 * buffer becomes full.
+			 */
+			ereport(ERROR,
+					errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+					errmsg("could not send data to the logical replication parallel apply worker"));
+			break;
+
+		case TRANS_PARALLEL_APPLY:
+
+			/*
+			 * If the parallel apply worker is applying spooled messages then
+			 * close the file before committing.
+			 */
+			if (stream_fd)
+				stream_close_file();
+
+			begin_replication_step();
+
+			INJECTION_POINT("parallel-worker-before-prepare", NULL);
+
+			/* Mark the transaction as prepared. */
+			apply_handle_prepare_internal(&prepare_data);
+
+			end_replication_step();
+
+			CommitTransactionCommand();
+			pgstat_report_stat(false);
+
+			store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr,
+								 InvalidTransactionId);
+
+			/*
+			 * It is okay not to set the local_end LSN for the prepare because
+			 * we always flush the prepare record. See apply_handle_prepare.
+			 */
+			MyParallelShared->last_commit_end = InvalidXLogRecPtr;
+			pa_commit_transaction();
+
+			pa_unlock_transaction(MyParallelShared->xid, AccessExclusiveLock);
+
+			pa_reset_subtrans();
+			break;
+
+		default:
+			elog(ERROR, "unexpected apply action: %d", (int) apply_action);
+			break;
+	}
+
+	remote_xid = InvalidTransactionId;
 
 	in_remote_transaction = false;
 
@@ -2377,6 +2526,12 @@ apply_handle_commit_prepared(StringInfo s)
 	/* There is no transaction when COMMIT PREPARED is called */
 	begin_replication_step();
 
+	if (TransactionIdIsValid(last_parallelized_remote_xid))
+	{
+		pa_wait_for_depended_transaction(last_parallelized_remote_xid);
+		last_parallelized_remote_xid = InvalidTransactionId;
+	}
+
 	/*
 	 * Update origin state so we can restart streaming from correct position
 	 * in case of crash.
@@ -2445,6 +2600,13 @@ apply_handle_rollback_prepared(StringInfo s)
 
 		/* There is no transaction when ABORT/ROLLBACK PREPARED is called */
 		begin_replication_step();
+
+		if (TransactionIdIsValid(last_parallelized_remote_xid))
+		{
+			pa_wait_for_depended_transaction(last_parallelized_remote_xid);
+			last_parallelized_remote_xid = InvalidTransactionId;
+		}
+
 		FinishPreparedTransaction(gid, false);
 		end_replication_step();
 		CommitTransactionCommand();
diff --git a/src/test/subscription/t/050_parallel_apply.pl b/src/test/subscription/t/050_parallel_apply.pl
index 91f95d4e11d..0494afa418c 100644
--- a/src/test/subscription/t/050_parallel_apply.pl
+++ b/src/test/subscription/t/050_parallel_apply.pl
@@ -19,6 +19,8 @@ if ($ENV{enable_injection_points} ne 'yes')
 # Initialize publisher node
 my $node_publisher = PostgreSQL::Test::Cluster->new('publisher');
 $node_publisher->init(allows_streaming => 'logical');
+$node_publisher->append_conf('postgresql.conf',
+    "max_prepared_transactions = 10");
 $node_publisher->start;
 
 # Create tables and insert initial data
@@ -44,7 +46,8 @@ my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
 $node_subscriber->init;
 $node_subscriber->append_conf('postgresql.conf', "log_min_messages = debug1");
 $node_subscriber->append_conf('postgresql.conf',
-	"max_logical_replication_workers = 10");
+	"max_logical_replication_workers = 10
+    max_prepared_transactions = 10");
 $node_subscriber->start;
 
 # Check if the extension injection_points is available, as it may be
@@ -268,4 +271,73 @@ is ($result, 1, 'inserts are replicated to subscriber');
 $node_publisher->safe_psql('postgres',
     "ALTER TABLE regress_tab REPLICA IDENTITY DEFAULT;");
 
+##################################################
+# Test that the prepared transaction can be applied in a parallel apply worker,
+# and that the commit order preservation work correctly.
+##################################################
+
+# Truncate the data for upcoming tests
+$node_publisher->safe_psql('postgres', "TRUNCATE TABLE regress_tab;");
+$node_publisher->wait_for_catchup('regress_sub');
+
+$node_subscriber->safe_psql('postgres',
+    "ALTER SUBSCRIPTION regress_sub DISABLE;");
+$node_subscriber->poll_query_until('postgres',
+	"SELECT count(*) = 0 FROM pg_stat_activity WHERE backend_type = 'logical replication apply worker'"
+);
+$node_subscriber->safe_psql(
+	'postgres', "
+    ALTER SUBSCRIPTION regress_sub SET (two_phase = on);
+    ALTER SUBSCRIPTION regress_sub ENABLE;");
+
+$result = $node_subscriber->safe_psql('postgres',
+    "SELECT count(1) FROM pg_stat_activity WHERE backend_type = 'logical replication parallel worker'");
+is($result, '0', "no parallel apply workers exist after restart");
+
+# Attach an injection_point. Parallel workers would wait before the prepare
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-prepare','wait');"
+);
+
+# PREPARE a transaction on publisher. It would be handled by a parallel apply
+# worker.
+$node_publisher->safe_psql('postgres', qq[
+    BEGIN;
+    INSERT INTO regress_tab VALUES (generate_series(51, 60), 'prepare');
+    PREPARE TRANSACTION 'regress_prepare';
+]);
+
+# Wait until the parallel worker enters the injection point.
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-prepare');
+
+$offset = -s $node_subscriber->logfile;
+
+# Insert tuples on publisher again. This transaction waits for the prepared
+# transaction
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(61, 70), 'test');");
+
+# Verify the parallel worker waits for the transaction
+$str = $node_subscriber->wait_for_log(qr/wait for depended xid ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /wait for depended xid ([1-9][0-9]+)/;
+
+ok(1, "commit order dependency from prepared transaction detected for parallel apply");
+
+# Wakeup the parallel worker
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-prepare');
+    SELECT injection_points_wakeup('parallel-worker-before-prepare');
+]);
+
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+
+# COMMIT the prepared transaction. It is always handled by the leader
+$node_publisher->safe_psql('postgres', "COMMIT PREPARED 'regress_prepare';");
+$node_publisher->wait_for_catchup('regress_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab");
+is ($result, 20, 'inserts are replicated to subscriber');
+
 done_testing();
-- 
2.43.0



  [application/octet-stream] v20-0004-Parallel-apply-non-streaming-transactions.patch (64.3K, ../../TY4PR01MB1771875A7A64B139EF8C8D05C94E62@TY4PR01MB17718.jpnprd01.prod.outlook.com/8-v20-0004-Parallel-apply-non-streaming-transactions.patch)
  download | inline diff:
From 3fe9bc11730a0b4603d6c33713f3d2017a6b918d Mon Sep 17 00:00:00 2001
From: Zhijie Hou <[email protected]>
Date: Mon, 8 Jun 2026 10:16:46 +0800
Subject: [PATCH v20 4/4] Parallel apply non-streaming transactions

--
Basic design
--

The leader worker assigns each non-streaming transaction to a parallel apply
worker. Before dispatching changes to a parallel worker, the leader verifies if
the current modification affects the same row (identitied by replica identity
key) as another ongoing transaction. If so, the leader sends a list of dependent
transaction IDs to the parallel worker, indicating that the parallel apply
worker must wait for these transactions to commit before proceeding.

The leader preserves publisher commit order for all transactions by instructing
the parallel worker to wait for the last transaction to commit before committing
the current transaction. (Note that the leader itself does not wait the
parallelized transaction to be committed unless partial serialization mode is
active, where the parallel apply worker can no longer be reused.) See the
"commit order" and "worker interaction" sections below for details.

Tracking dependencies is necessary even when commit order is preserved.
Consider two transactions: TX-1 (INSERT row 1) and TX-2 (DELETE row 1). If
both are allowed to apply in parallel, TX-2's DELETE could be applied before
TX-1's INSERT, resulting in a delete_missing conflict.

Each parallel apply worker records the local end LSN of the transaction it
applies in shared memory. Subsequently, the leader gathers these local end LSNs
and logs them in the local 'lsn_mapping' for verifying whether they have been
flushed to disk (following the logic in get_flush_position()).

If no parallel apply worker is available, the leader will apply the transaction
independently.

For further details, please refer to the following:

--
dedendency tracking
--

The leader maintains a local hash table, using the remote change's replica
identity column values and relid as keys, with remote transaction IDs as values.
Before sending changes to the parallel apply worker, the leader computes a hash
using RI key values and the relid of the current change to search the hash
table. If an existing entry is found, the leader first updates the hash entry
with the receiving remote xid then tells the parallel worker to wait for it.

If the remote relation lacks a replica identity (RI), it indicates that only
INSERT can be replicated for this table. In such cases, the leader skips
dependency checks, allowing the parallel apply worker to proceed with applying
changes without delay. This is because the only potential conflict could happen
is related to the local unique key or foreign key, which that is yet to be
implemented.

In cases of TRUNCATE or remote schema changes affecting the entire table, the
leader retrieves all remote xids touching the same table (via sequential scans
of the hash table) and tells the parallel worker to wait for those transactions
to commit.

Hash entries are cleaned up once the transaction corresponding to the remote xid
in the entry has been committed. Clean-up typically occurs when collecting the
flush position of each transaction, but is forced if the hash table exceeds a
set threshold.

--
dedendency waiting
--

If a transaction is relied upon by others, the leader adds its xid to a shared
hash table. The shared hash table entry is cleared by the parallel apply worker
upon completing the transaction. Workers needing to wait for a transaction check
the shared hash table entry; if present, they lock the transaction ID (using
pa_lock_transaction). If absent, it indicates the transaction has been
committed, negating the need to wait.

--
commit order
--

We preserve publisher commit order for all transactions for two reasons:

1) User-visible consistency

Out-of-order commits can expose states on the subscriber that were never visible
on the publisher.

For example, suppose a user updates table A and then updates table B on the
publisher. If the subscriber commits those transactions out of order, a query
that sees the latest row in B might still see stale data in A. Although eventual
consistency would still be reached, that behavior may be unacceptable for some
users. In the future, we could provide a GUC to allow out-of-order commits for
users who prefer higher parallelism.

2) Replication progress tracking

We currently track replication progress using transaction commit LSNs. With
out-of-order commits, this becomes ambiguous after failures.

For example, if TX-2 is applied before TX-1 and replication stops due to an
error, we cannot reliably determine whether TX-1 was applied before restart.
As a result, transactions that were already committed on the subscriber may
be replayed.

--
worker interaction
--

After sending the COMMIT message for a transaction, the leader apply worker
does not wait for the parallel apply worker to finish applying that
transaction. Instead, it sends a PA_MSG_XACT_DEPENDENCY message to
the parallel apply worker, instructing it to wait for the last transaction to
commit. This allows the leader to remain busy receiving and dispatching
changes to more parallel apply workers, enabling greater parallelism in
transaction application.

Author: Zhijie Hou <[email protected]>
Author: Hayato Kuroda <[email protected]>
---
 .../replication/logical/applyparallelworker.c | 348 +++++++++++++++--
 src/backend/replication/logical/proto.c       |  38 ++
 src/backend/replication/logical/relation.c    |  31 ++
 src/backend/replication/logical/worker.c      | 358 ++++++++++++++++--
 src/include/replication/logicalproto.h        |   2 +
 src/include/replication/logicalrelation.h     |   2 +
 src/include/replication/worker_internal.h     |  10 +-
 src/test/subscription/meson.build             |   2 +-
 src/test/subscription/t/001_rep_changes.pl    |   2 +
 src/test/subscription/t/010_truncate.pl       |   2 +-
 src/test/subscription/t/015_stream.pl         |   8 +-
 src/test/subscription/t/026_stats.pl          |   1 +
 src/test/subscription/t/027_nosuperuser.pl    |   1 +
 src/test/subscription/t/050_parallel_apply.pl | 271 +++++++++++++
 src/tools/pgindent/typedefs.list              |   2 +
 15 files changed, 999 insertions(+), 79 deletions(-)
 create mode 100644 src/test/subscription/t/050_parallel_apply.pl

diff --git a/src/backend/replication/logical/applyparallelworker.c b/src/backend/replication/logical/applyparallelworker.c
index 30d31e02ac1..4a7f65345d8 100644
--- a/src/backend/replication/logical/applyparallelworker.c
+++ b/src/backend/replication/logical/applyparallelworker.c
@@ -14,6 +14,9 @@
  * ParallelApplyWorkerInfo which is required so the leader worker and parallel
  * apply workers can communicate with each other.
  *
+ * Streaming transactions
+ * ======================
+ *
  * The parallel apply workers are assigned (if available) as soon as xact's
  * first stream is received for subscriptions that have set their 'streaming'
  * option as parallel. The leader apply worker will send changes to this new
@@ -36,12 +39,9 @@
  * its information is added to the ParallelApplyWorkerPool. Once the worker
  * finishes applying the transaction, it is marked as available for re-use.
  * Now, before starting a new worker to apply the streaming transaction, we
- * check the list for any available worker. Note that we retain a maximum of
- * half the max_parallel_apply_workers_per_subscription workers in the pool and
- * after that, we simply exit the worker after applying the transaction.
- *
- * XXX This worker pool threshold is arbitrary and we can provide a GUC
- * variable for this in the future if required.
+ * check the list for any available worker. We do not stop workers in the pool
+ * unless partial of the changes of the transaction are serialized due to a send
+ * timeout.
  *
  * The leader apply worker will create a separate dynamic shared memory segment
  * when each parallel apply worker starts. The reason for this design is that
@@ -152,6 +152,75 @@
  * session-level locks because both locks could be acquired outside the
  * transaction, and the stream lock in the leader needs to persist across
  * transaction boundaries i.e. until the end of the streaming transaction.
+ *
+ * Non-streaming transactions
+ * ======================
+ * The handling is similar to streaming transactions, but including few
+ * differences:
+ *
+ * Transaction dependency
+ * ----------------------
+ * Before dispatching changes to a parallel worker, the leader verifies if the
+ * current modification affects the same row (identitied by replica identity
+ * key) as another ongoing transaction (see handle_dependency_on_change for
+ * details). If so, the leader sends a list of dependent transaction IDs to the
+ * parallel worker, indicating that the parallel apply worker must wait for
+ * these transactions to commit before proceeding.
+ *
+ * Tracking dependencies is necessary even when commit order is preserved.
+ * Consider two transactions: TX-1 (INSERT row 1) and TX-2 (DELETE row 1). If
+ * both are allowed to apply in parallel, TX-2's DELETE could be applied before
+ * TX-1's INSERT, resulting in a delete_missing conflict.
+ *
+ * Commit order
+ * ------------
+ * We preserve publisher commit order for all transactions for two reasons:
+ *
+ * 1) User-visible consistency
+ *
+ * Out-of-order commits can expose states on the subscriber that were never
+ * visible on the publisher.
+ *
+ * For example, suppose a user updates table A and then updates table B on the
+ * publisher. If the subscriber commits those transactions out of order, a
+ * query that sees the latest row in B might still see stale data in A.
+ * Although eventual consistency would still be reached, that behavior may be
+ * unacceptable for some users. In the future, we could provide a subscription
+ * option to allow out-of-order commits for users who prefer higher parallelism.
+ *
+ * 2) Replication progress tracking
+ *
+ * We currently track replication progress using the last transaction's commit
+ * LSNs. With out-of-order commits, this becomes ambiguous after failures.
+ *
+ * For example, if TX-2 is applied before TX-1 and replication stops due to an
+ * error, we cannot reliably determine whether TX-1 was applied before restart.
+ * As a result, transactions that were already committed on the subscriber may
+ * be replayed.
+ *
+ * Worker interaction
+ * ------------
+ * After sending the COMMIT message for a transaction, the leader apply worker
+ * does not wait for the parallel apply worker to finish applying that
+ * transaction. Instead, it sends a PA_MSG_XACT_DEPENDENCY message to
+ * the parallel apply worker, instructing it to wait for the last transaction to
+ * commit. This allows the leader to remain busy receiving and dispatching
+ * changes to more parallel apply workers, enabling greater parallelism in
+ * transaction application.
+ *
+ * Locking considerations
+ * ----------------------
+ * When handling a PA_MSG_RELMAP message, the worker attempts
+ * to acquire the transaction lock of the depended transaction and releases it
+ * immediately after acquisition (see pa_wait_for_depended_transaction). This
+ * allows deadlock detection when one worker (either leader or parallel apply
+ * worker) is waiting for a dependency on a transaction being applied by another
+ * worker, while that other worker is also blocked by a lock held by the first
+ * worker.
+ *
+ * The lock graph for the above example will look as follows: Worker_1 (waiting
+ * for depended transaction to finish) -> Worker_2 (waiting to acquire a
+ * relation lock) -> Worker_1
  *-------------------------------------------------------------------------
  */
 
@@ -316,6 +385,7 @@ static ParallelTransState pa_get_xact_state(ParallelApplyWorkerShared *wshared);
 static PartialFileSetState pa_get_fileset_state(void);
 static void pa_attach_parallelized_txn_hash(dsa_handle *pa_dsa_handle,
 											dshash_table_handle *pa_dshash_handle);
+static void write_internal_relation(StringInfo s, LogicalRepRelation *rel);
 
 /*
  * Returns true if it is OK to start a parallel apply worker, false otherwise.
@@ -433,6 +503,7 @@ pa_setup_dsm(ParallelApplyWorkerInfo *winfo)
 	shared = shm_toc_allocate(toc, sizeof(ParallelApplyWorkerShared));
 	SpinLockInit(&shared->mutex);
 
+	shared->xid = InvalidTransactionId;
 	shared->xact_state = PARALLEL_TRANS_UNKNOWN;
 	pg_atomic_init_u32(&(shared->pending_stream_count), 0);
 	shared->last_commit_end = InvalidXLogRecPtr;
@@ -476,6 +547,8 @@ pa_launch_parallel_worker(void)
 	MemoryContext oldcontext;
 	bool		launched;
 	ParallelApplyWorkerInfo *winfo;
+	dsa_handle	pa_dsa_handle;
+	dshash_table_handle pa_dshash_handle;
 	ListCell   *lc;
 
 	/* Try to get an available parallel apply worker from the worker pool. */
@@ -485,8 +558,36 @@ pa_launch_parallel_worker(void)
 
 		if (!winfo->in_use)
 			return winfo;
+
+		/*
+		 * The leader does not explicitly free workers that handle non-streaming
+		 * transactions, so we check whether the transaction has committed, and
+		 * if so, reuse the worker.
+		 */
+		if (!winfo->stream_txn &&
+			pa_get_xact_state(winfo->shared) == PARALLEL_TRANS_FINISHED)
+		{
+			/*
+			 * Save the local commit LSN of the last transaction applied by
+			 * this worker before reusing it for another transaction. This WAL
+			 * position is crucial for determining the flush position in
+			 * responses to the publisher (see get_flush_position()).
+			 */
+			(void) pa_get_last_commit_end(winfo->shared->xid, false, NULL);
+			return winfo;
+		}
 	}
 
+	pa_attach_parallelized_txn_hash(&pa_dsa_handle, &pa_dshash_handle);
+
+	/*
+	 * Return if the number of parallel apply workers has reached the maximum
+	 * limit.
+	 */
+	if (list_length(ParallelApplyWorkerPool) ==
+		max_parallel_apply_workers_per_subscription)
+		return NULL;
+
 	/*
 	 * Start a new parallel apply worker.
 	 *
@@ -514,18 +615,50 @@ pa_launch_parallel_worker(void)
 										dsm_segment_handle(winfo->dsm_seg),
 										false);
 
-	if (launched)
-	{
-		ParallelApplyWorkerPool = lappend(ParallelApplyWorkerPool, winfo);
-	}
-	else
+	if (!launched)
 	{
+		MemoryContextSwitchTo(oldcontext);
 		pa_free_worker_info(winfo);
-		winfo = NULL;
+		return NULL;
 	}
 
+	ParallelApplyWorkerPool = lappend(ParallelApplyWorkerPool, winfo);
+
 	MemoryContextSwitchTo(oldcontext);
 
+	/*
+	 * Send all existing remote relation information to the parallel apply
+	 * worker. This allows the parallel worker to initialize the
+	 * LogicalRepRelMapEntry locally before applying remote changes. This is
+	 * needed since the walsender does not send remote relation information
+	 * with every transaction.
+	 */
+	if (logicalrep_get_num_rels())
+	{
+		StringInfoData out;
+		shm_mq_result result;
+
+		initStringInfo(&out);
+
+		write_internal_relation(&out, NULL);
+
+		/*
+		 * Send relation information synchronously rather than using
+		 * non-blocking send (pa_send_data). This avoids the need to serialize
+		 * the data, which would add complexity for the parallel apply worker to
+		 * deserialize outside of transaction application.
+		 *
+		 * This is safe from deadlocks because a newly started worker does not
+		 * hold any strong locks when processing relation information.
+		 */
+		result = shm_mq_send(winfo->mq_handle, out.len, out.data, false, true);
+
+		if (result != SHM_MQ_SUCCESS)
+			ereport(ERROR,
+					errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+					errmsg("could not send remote relation information to the logical replication parallel apply worker"));
+	}
+
 	return winfo;
 }
 
@@ -630,25 +763,27 @@ pa_free_worker(ParallelApplyWorkerInfo *winfo)
 {
 	Assert(!am_parallel_apply_worker());
 	Assert(winfo->in_use);
-	Assert(pa_get_xact_state(winfo->shared) == PARALLEL_TRANS_FINISHED);
+	Assert(!winfo->stream_txn ||
+		   pa_get_xact_state(winfo->shared) == PARALLEL_TRANS_FINISHED);
 
 	if (!hash_search(ParallelApplyTxnHash, &winfo->shared->xid, HASH_REMOVE, NULL))
 		elog(ERROR, "hash table corrupted");
 
 	/*
-	 * Stop the worker if there are enough workers in the pool.
+	 * XXX we stop the worker if the leader apply worker serialize part of the
+	 * transaction data due to a send timeout. This is because the message could
+	 * be partially written to the queue and there is no way to clean the queue
+	 * other than resending the message until it succeeds. Instead of trying to
+	 * send the data which anyway would have been serialized and then letting
+	 * the parallel apply worker deal with the spurious message, we stop the
+	 * worker.
 	 *
-	 * XXX Additionally, we also stop the worker if the leader apply worker
-	 * serialize part of the transaction data due to a send timeout. This is
-	 * because the message could be partially written to the queue and there
-	 * is no way to clean the queue other than resending the message until it
-	 * succeeds. Instead of trying to send the data which anyway would have
-	 * been serialized and then letting the parallel apply worker deal with
-	 * the spurious message, we stop the worker.
+	 * For other cases, we do not stop workers once started. All transactions
+	 * (whether streamed or not) are assigned to parallel apply workers, so
+	 * restarting workers frequently would only increase CPU overhead and slow
+	 * down the leader's ability to dispatch changes to workers.
 	 */
-	if (winfo->serialize_changes ||
-		list_length(ParallelApplyWorkerPool) >
-		(max_parallel_apply_workers_per_subscription / 2))
+	if (winfo->serialize_changes)
 	{
 		logicalrep_pa_worker_stop(winfo);
 		pa_free_worker_info(winfo);
@@ -781,15 +916,17 @@ pa_process_spooled_messages_if_required(void)
 /*
  * Get the local end LSN for a transaction applied by a parallel apply worker.
  *
+ * Set delete_entry to true if you intend to remove the transaction from the
+ * ParallelApplyTxnHash after collecting its LSN. Otherwise, the local end LSN
+ * is copied from shared memory into the local entry of ParallelApplyTxnHash for
+ * later collection by the leader.
+ *
  * If the parallel apply worker did not write any changes during the transaction
  * application due to situations like update/delete_missing or a before trigger,
  * the *skipped_write will be set to true.
- *
- * Once the LSN is retrieved, the transaction entry is removed from
- * ParallelApplyTxnHash.
  */
 XLogRecPtr
-pa_get_last_commit_end(TransactionId xid, bool *skipped_write)
+pa_get_last_commit_end(TransactionId xid, bool delete_entry, bool *skipped_write)
 {
 	bool		found;
 	ParallelApplyWorkerEntry *entry;
@@ -815,7 +952,8 @@ pa_get_last_commit_end(TransactionId xid, bool *skipped_write)
 	winfo = entry->winfo;
 	if (winfo == NULL)
 	{
-		if (!hash_search(ParallelApplyTxnHash, &xid, HASH_REMOVE, NULL))
+		if (delete_entry &&
+			!hash_search(ParallelApplyTxnHash, &xid, HASH_REMOVE, NULL))
 			elog(ERROR, "hash table corrupted");
 
 		if (skipped_write)
@@ -838,7 +976,8 @@ pa_get_last_commit_end(TransactionId xid, bool *skipped_write)
 	elog(DEBUG1, "store local commit %X/%X end to txn entry: %u",
 		 LSN_FORMAT_ARGS(entry->local_end), xid);
 
-	if (!hash_search(ParallelApplyTxnHash, &xid, HASH_REMOVE, NULL))
+	if (delete_entry &&
+		!hash_search(ParallelApplyTxnHash, &xid, HASH_REMOVE, NULL))
 		elog(ERROR, "hash table corrupted");
 
 	return entry->local_end;
@@ -1091,6 +1230,9 @@ pa_shutdown(int code, Datum arg)
 				   INVALID_PROC_NUMBER);
 
 	dsm_detach((dsm_segment *) DatumGetPointer(arg));
+
+	if (parallel_apply_dsa_area)
+		dsa_detach(parallel_apply_dsa_area);
 }
 
 /*
@@ -1402,7 +1544,6 @@ pa_send_data(ParallelApplyWorkerInfo *winfo, Size nbytes, const void *data)
 	shm_mq_result result;
 	TimestampTz startTime = 0;
 
-	Assert(!IsTransactionState());
 	Assert(!winfo->serialize_changes);
 
 	/*
@@ -1454,6 +1595,58 @@ pa_send_data(ParallelApplyWorkerInfo *winfo, Size nbytes, const void *data)
 	}
 }
 
+/*
+ * Distribute remote relation information to all active parallel apply workers
+ * that require it.
+ */
+void
+pa_distribute_schema_changes_to_workers(LogicalRepRelation *rel)
+{
+	StringInfoData out;
+
+	/* Only leader apply workers can distribute schema changes */
+	if (!am_leader_apply_worker())
+		return;
+
+	/* Quick exit if there are no parallel apply workers */
+	if (!ParallelApplyWorkerPool)
+		return;
+
+	initStringInfo(&out);
+
+	write_internal_relation(&out, rel);
+
+	foreach_ptr(ParallelApplyWorkerInfo, winfo, ParallelApplyWorkerPool)
+	{
+		/*
+		 * Skip the worker responsible for the current transaction, as the
+		 * relation information has already been sent to it.
+		 */
+		if (winfo == stream_apply_worker)
+			continue;
+
+		/*
+		 * Skip the worker that is in serialize mode, as they will soon stop
+		 * once they finish applying the transaction.
+		 */
+		if (winfo->serialize_changes)
+			continue;
+
+		elog(DEBUG1, "distributing schema changes to pa workers");
+
+		if (pa_send_data(winfo, out.len, out.data))
+			continue;
+
+		/*
+		 * TODO: Support switching to PARTIAL_SERIALIZE mode when the send
+		 * buffer becomes full.
+		 */
+		ereport(ERROR,
+				errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+				errmsg("could not send data to the logical replication parallel apply worker for subscription"));
+	}
+}
+
 /*
  * Switch to PARTIAL_SERIALIZE mode for the current transaction -- this means
  * that the current data and any subsequent data for this transaction will be
@@ -1870,25 +2063,41 @@ pa_decr_and_wait_stream_block(void)
 void
 pa_xact_finish(ParallelApplyWorkerInfo *winfo, XLogRecPtr remote_lsn)
 {
+	XLogRecPtr	local_lsn = InvalidXLogRecPtr;
+	TransactionId pa_remote_xid = winfo->shared->xid;
+
 	Assert(am_leader_apply_worker());
 
 	/*
-	 * Unlock the shared object lock so that parallel apply worker can
-	 * continue to receive and apply changes.
+	 * Unlock the shared object lock taken for streaming transactions so that
+	 * parallel apply worker can continue to receive and apply changes.
 	 */
-	pa_unlock_stream(winfo->shared->xid, AccessExclusiveLock);
+	if (winfo->stream_txn)
+		pa_unlock_stream(winfo->shared->xid, AccessExclusiveLock);
 
 	/*
-	 * Wait for that worker to finish. This is necessary to maintain commit
-	 * order which avoids failures due to transaction dependencies and
-	 * deadlocks.
+	 * Wait for that worker for streaming transaction to finish. This is
+	 * necessary to maintain commit order which avoids failures due to
+	 * transaction dependencies and deadlocks.
+	 *
+	 * For transactions in partial seralize mode, wait for stop as well as the
+	 * worker is anyway cannot be reused anymore (see pa_free_worker() for
+	 * details).
 	 */
-	pa_wait_for_xact_finish(winfo);
+	if (winfo->serialize_changes || winfo->stream_txn)
+	{
+		pa_wait_for_xact_finish(winfo);
+
+		local_lsn = winfo->shared->last_commit_end;
+		pa_remote_xid = InvalidTransactionId;
+
+		pa_free_worker(winfo);
+	}
 
 	if (XLogRecPtrIsValid(remote_lsn))
-		store_flush_position(remote_lsn, winfo->shared->last_commit_end);
+		store_flush_position(remote_lsn, local_lsn, pa_remote_xid);
 
-	pa_free_worker(winfo);
+	pa_set_stream_apply_worker(NULL);
 }
 
 /*
@@ -2022,3 +2231,62 @@ pa_wait_for_depended_transaction(TransactionId xid)
 
 	elog(DEBUG1, "finish waiting for depended xid %u", xid);
 }
+
+/*
+ * Mark the transaction state as finished and remove the shared hash entry.
+ */
+void
+pa_commit_transaction(void)
+{
+	TransactionId xid = MyParallelShared->xid;
+
+	SpinLockAcquire(&MyParallelShared->mutex);
+	MyParallelShared->xact_state = PARALLEL_TRANS_FINISHED;
+	SpinLockRelease(&MyParallelShared->mutex);
+
+	dshash_delete_key(parallelized_txns, &xid);
+	elog(DEBUG1, "xid %u committed", xid);
+}
+
+/*
+ * Write internal relation description to the output stream.
+ */
+static void
+write_internal_relation(StringInfo s, LogicalRepRelation *rel)
+{
+	pq_sendbyte(s, LOGICAL_REP_MSG_INTERNAL_MESSAGE);
+	pq_sendbyte(s, PA_MSG_RELMAP);
+
+	if (rel)
+	{
+		pq_sendint(s, 1, 4);
+		logicalrep_write_internal_rel(s, rel);
+	}
+	else
+	{
+		pq_sendint(s, logicalrep_get_num_rels(), 4);
+		logicalrep_write_all_rels(s);
+	}
+}
+
+/*
+ * Register a transaction to the shared hash table.
+ *
+ * This function is called by the leader during the commit phase of non-streamed
+ * transactions. The parallel apply worker that applies the transaction will
+ * remove it from the hash table upon completion.
+ */
+void
+pa_add_parallelized_transaction(TransactionId xid)
+{
+	bool		found;
+	ParallelizedTxnEntry *txn_entry;
+
+	Assert(parallelized_txns);
+	Assert(TransactionIdIsValid(xid));
+	Assert(am_leader_apply_worker());
+
+	txn_entry = dshash_find_or_insert(parallelized_txns, &xid, &found);
+
+	dshash_release_lock(parallelized_txns, txn_entry);
+}
diff --git a/src/backend/replication/logical/proto.c b/src/backend/replication/logical/proto.c
index a0cac05cdcd..eb1a846ce0c 100644
--- a/src/backend/replication/logical/proto.c
+++ b/src/backend/replication/logical/proto.c
@@ -691,6 +691,44 @@ logicalrep_write_rel(StringInfo out, TransactionId xid, Relation rel,
 	logicalrep_write_attrs(out, rel, columns, include_gencols_type);
 }
 
+/*
+ * Write internal relation description to the output stream.
+ */
+void
+logicalrep_write_internal_rel(StringInfo out, LogicalRepRelation *rel)
+{
+	pq_sendint32(out, rel->remoteid);
+
+	/* Write relation name */
+	pq_sendstring(out, rel->nspname);
+	pq_sendstring(out, rel->relname);
+
+	/* Write the replica identity. */
+	pq_sendbyte(out, rel->replident);
+
+	/* Write attribute description */
+	pq_sendint16(out, rel->natts);
+
+	for (int i = 0; i < rel->natts; i++)
+	{
+		uint8		flags = 0;
+
+		if (bms_is_member(i, rel->attkeys))
+			flags |= LOGICALREP_IS_REPLICA_IDENTITY;
+
+		pq_sendbyte(out, flags);
+
+		/* attribute name */
+		pq_sendstring(out, rel->attnames[i]);
+
+		/* attribute type id */
+		pq_sendint32(out, rel->atttyps[i]);
+
+		/* ignore attribute mode for now */
+		pq_sendint32(out, 0);
+	}
+}
+
 /*
  * Read the relation info from stream and return as LogicalRepRelation.
  */
diff --git a/src/backend/replication/logical/relation.c b/src/backend/replication/logical/relation.c
index ab6313eb1bc..e1ce183dfd3 100644
--- a/src/backend/replication/logical/relation.c
+++ b/src/backend/replication/logical/relation.c
@@ -961,6 +961,37 @@ FindLogicalRepLocalIndex(Relation localrel, LogicalRepRelation *remoterel,
 	return InvalidOid;
 }
 
+/*
+ * Get the number of entries in the LogicalRepRelMap.
+ */
+int
+logicalrep_get_num_rels(void)
+{
+	if (LogicalRepRelMap == NULL)
+		return 0;
+
+	return hash_get_num_entries(LogicalRepRelMap);
+}
+
+/*
+ * Write all the remote relation information from the LogicalRepRelMapEntry to
+ * the output stream.
+ */
+void
+logicalrep_write_all_rels(StringInfo out)
+{
+	LogicalRepRelMapEntry *entry;
+	HASH_SEQ_STATUS status;
+
+	if (LogicalRepRelMap == NULL)
+		return;
+
+	hash_seq_init(&status, LogicalRepRelMap);
+
+	while ((entry = (LogicalRepRelMapEntry *) hash_seq_search(&status)) != NULL)
+		logicalrep_write_internal_rel(out, &entry->remoterel);
+}
+
 /*
  * Get the LogicalRepRelMapEntry corresponding to the given relid without
  * opening the local relation.
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 19cfbcb1cb7..d9be0e360db 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -290,6 +290,7 @@
 #include "tcop/tcopprot.h"
 #include "utils/acl.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/lsyscache.h"
 #include "utils/memutils.h"
@@ -514,6 +515,8 @@ static List *on_commit_wakeup_workers_subids = NIL;
 
 bool		in_remote_transaction = false;
 static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+static TransactionId remote_xid = InvalidTransactionId;
+static TransactionId last_parallelized_remote_xid = InvalidTransactionId;
 
 /* fields valid only when processing streamed transaction */
 static bool in_streamed_transaction = false;
@@ -723,6 +726,7 @@ static void on_exit_clear_xact_state(int code, Datum arg);
 
 static void send_internal_dependencies(ParallelApplyWorkerInfo *winfo,
 									   List *depends_on_xids);
+static void build_dependency_with_last_committed_txn(ParallelApplyWorkerInfo *winfo);
 
 /*
  * Compute the hash value for entries in the replica_identity_table.
@@ -842,7 +846,7 @@ cleanup_committed_replica_identity_entries(void)
 			XLogRecPtrIsValid(pos->local_end))
 			continue;
 
-		pos->local_end = pa_get_last_commit_end(pos->pa_remote_xid,
+		pos->local_end = pa_get_last_commit_end(pos->pa_remote_xid, false,
 												&skipped_write);
 
 		elog(DEBUG1,
@@ -1481,14 +1485,14 @@ handle_streamed_transaction(LogicalRepMsgType action, StringInfo s)
 	TransApplyAction apply_action;
 	StringInfoData original_msg;
 
+	if (!in_streamed_transaction)
+		return false;
+
 	apply_action = get_transaction_apply_action(stream_xid, &winfo);
 
 	/* not in streaming mode */
 	if (apply_action == TRANS_LEADER_APPLY)
-	{
-		handle_dependency_on_change(action, s, InvalidTransactionId, winfo);
 		return false;
-	}
 
 	Assert(TransactionIdIsValid(stream_xid));
 
@@ -1563,6 +1567,73 @@ handle_streamed_transaction(LogicalRepMsgType action, StringInfo s)
 	}
 }
 
+/*
+ * Handle non-streaming transactions when parallel apply is in use.
+ *
+ * This function runs only in the leader apply worker while processing a remote
+ * transaction. It checks whether the current change has dependencies on
+ * preceding parallelized transactions and decides whether to send the change to
+ * a parallel apply worker.
+ *
+ * Returns true if the change has been dispatched to a parallel worker,
+ * indicating the leader does not need to apply it directly. Returns false
+ * otherwise.
+ *
+ * Exception: If the message being processed is LOGICAL_REP_MSG_RELATION or
+ * LOGICAL_REP_MSG_TYPE, return false even if the message needs to be sent to a
+ * parallel apply worker.
+ */
+static bool
+handle_parallelized_transaction(LogicalRepMsgType action, StringInfo s)
+{
+	ParallelApplyWorkerInfo *winfo;
+	TransApplyAction apply_action;
+
+	/*
+	 * Dependency checking for non-streaming transactions is only required in
+	 * the leader apply worker during a remote transaction.
+	 */
+	if (!in_remote_transaction || !am_leader_apply_worker())
+		return false;
+
+	apply_action = get_transaction_apply_action(remote_xid, &winfo);
+
+	/* not assigned to parallel apply worker, apply in leader */
+	if (apply_action == TRANS_LEADER_APPLY)
+	{
+		handle_dependency_on_change(action, s, InvalidTransactionId, winfo);
+		return false;
+	}
+
+	Assert(TransactionIdIsValid(remote_xid));
+
+	handle_dependency_on_change(action, s, remote_xid, winfo);
+
+	switch (apply_action)
+	{
+		case TRANS_LEADER_SEND_TO_PARALLEL:
+			Assert(winfo);
+
+			/* Always update relation and type cache in leader apply worker */
+			if (pa_send_data(winfo, s->len, s->data))
+				return (action != LOGICAL_REP_MSG_RELATION &&
+						action != LOGICAL_REP_MSG_TYPE);
+
+			/*
+			 * TODO: Support switching to PARTIAL_SERIALIZE mode when the send
+			 * buffer becomes full.
+			 */
+			ereport(ERROR,
+					errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+					errmsg("could not send data to the logical replication parallel apply worker"));
+			return false;	/* silence compiler warning */
+
+		default:
+			elog(ERROR, "unexpected apply action: %d", (int) apply_action);
+			return false;		/* silence compiler warning */
+	}
+}
+
 /*
  * Executor state preparation for evaluation of constraint expressions,
  * indexes and triggers for the specified relation.
@@ -1924,17 +1995,61 @@ static void
 apply_handle_begin(StringInfo s)
 {
 	LogicalRepBeginData begin_data;
+	ParallelApplyWorkerInfo *winfo;
+	TransApplyAction apply_action;
 
 	/* There must not be an active streaming transaction. */
 	Assert(!TransactionIdIsValid(stream_xid));
 
 	logicalrep_read_begin(s, &begin_data);
-	set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
+
+	remote_xid = begin_data.xid;
+
+	set_apply_error_context_xact(remote_xid, begin_data.final_lsn);
 
 	remote_final_lsn = begin_data.final_lsn;
 
 	maybe_start_skipping_changes(begin_data.final_lsn);
 
+	pa_allocate_worker(remote_xid, false);
+
+	apply_action = get_transaction_apply_action(remote_xid, &winfo);
+
+	elog(DEBUG1, "new remote_xid %u", remote_xid);
+	switch (apply_action)
+	{
+		case TRANS_LEADER_APPLY:
+			break;
+
+		case TRANS_LEADER_SEND_TO_PARALLEL:
+			Assert(winfo);
+
+			if (pa_send_data(winfo, s->len, s->data))
+			{
+				pa_set_stream_apply_worker(winfo);
+				break;
+			}
+
+			/*
+			 * TODO: Support switching to PARTIAL_SERIALIZE mode when the send
+			 * buffer becomes full.
+			 */
+			ereport(ERROR,
+					errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+					errmsg("could not send data to the logical replication parallel apply worker"));
+			break;
+
+		case TRANS_PARALLEL_APPLY:
+			/* Hold the lock until the end of the transaction. */
+			pa_lock_transaction(MyParallelShared->xid, AccessExclusiveLock);
+			pa_set_xact_state(MyParallelShared, PARALLEL_TRANS_STARTED);
+			break;
+
+		default:
+			elog(ERROR, "unexpected apply action: %d", (int) apply_action);
+			break;
+	}
+
 	in_remote_transaction = true;
 
 	pgstat_report_activity(STATE_RUNNING, NULL);
@@ -1957,12 +2072,33 @@ send_internal_dependencies(ParallelApplyWorkerInfo *winfo, List *depends_on_xids
 	foreach_xid(xid, depends_on_xids)
 		pq_sendint32(&dependencies, xid);
 
-	if (pa_send_data(winfo, dependencies.len, dependencies.data))
+	/*
+	 * TODO: Support switching to PARTIAL_SERIALIZE mode when the send
+	 * buffer becomes full.
+	 */
+	if (!pa_send_data(winfo, dependencies.len, dependencies.data))
 		ereport(ERROR,
 				errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
 				errmsg("could not send data to the logical replication parallel apply worker"));
 }
 
+/*
+ * Make a dependency between this and the lastly committed transaction.
+ *
+ * Sends an INTERNAL_DEPENDENCY message to the parallel apply worker,
+ * instructing it to wait for the last committed transaction to finish before
+ * committing its own, thereby preserving commit order.
+ */
+static void
+build_dependency_with_last_committed_txn(ParallelApplyWorkerInfo *winfo)
+{
+	/* Skip if transactions have not been applied yet */
+	if (!TransactionIdIsValid(last_parallelized_remote_xid))
+		return;
+
+	send_internal_dependencies(winfo, list_make1_xid(last_parallelized_remote_xid));
+}
+
 /*
  * Handle COMMIT message.
  *
@@ -1972,6 +2108,8 @@ static void
 apply_handle_commit(StringInfo s)
 {
 	LogicalRepCommitData commit_data;
+	ParallelApplyWorkerInfo *winfo;
+	TransApplyAction apply_action;
 
 	logicalrep_read_commit(s, &commit_data);
 
@@ -1982,7 +2120,92 @@ apply_handle_commit(StringInfo s)
 								 LSN_FORMAT_ARGS(commit_data.commit_lsn),
 								 LSN_FORMAT_ARGS(remote_final_lsn))));
 
-	apply_handle_commit_internal(&commit_data);
+	apply_action = get_transaction_apply_action(remote_xid, &winfo);
+
+	switch (apply_action)
+	{
+		case TRANS_LEADER_APPLY:
+
+			/*
+			 * Apart from parallelized transactions, we do not have to
+			 * register this transaction to parallelized_txns. The commit
+			 * ordering is always preserved.
+			 */
+
+			/* Wait until the last transaction finishes */
+			if (TransactionIdIsValid(last_parallelized_remote_xid))
+			{
+				pa_wait_for_depended_transaction(last_parallelized_remote_xid);
+				last_parallelized_remote_xid = InvalidTransactionId;
+			}
+
+			apply_handle_commit_internal(&commit_data);
+
+			break;
+
+		case TRANS_LEADER_SEND_TO_PARALLEL:
+			Assert(winfo);
+
+			/*
+			 * Mark this transaction as parallelized. This ensures that
+			 * upcoming transactions wait until this transaction is committed.
+			 */
+			pa_add_parallelized_transaction(remote_xid);
+
+			/*
+			 * Build a dependency between this transaction and the lastly
+			 * committed transaction to preserve the commit order.
+			 */
+			build_dependency_with_last_committed_txn(winfo);
+
+			if (pa_send_data(winfo, s->len, s->data))
+			{
+				/* Cache the remote_xid */
+				last_parallelized_remote_xid = remote_xid;
+
+				/* Finish processing the transaction. */
+				pa_xact_finish(winfo, commit_data.end_lsn);
+				break;
+			}
+
+			/*
+			 * TODO: Support switching to PARTIAL_SERIALIZE mode when the send
+			 * buffer becomes full.
+			 */
+			ereport(ERROR,
+					errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+					errmsg("could not send data to the logical replication parallel apply worker"));
+			break;
+
+		case TRANS_PARALLEL_APPLY:
+
+			/*
+			 * If the parallel apply worker is applying spooled messages then
+			 * close the file before committing.
+			 */
+			if (stream_fd)
+				stream_close_file();
+
+			INJECTION_POINT("parallel-worker-before-commit", NULL);
+
+			apply_handle_commit_internal(&commit_data);
+
+			MyParallelShared->last_commit_end = XactLastCommitEnd;
+
+			pa_commit_transaction();
+
+			pa_unlock_transaction(remote_xid, AccessExclusiveLock);
+			break;
+
+		default:
+			elog(ERROR, "unexpected apply action: %d", (int) apply_action);
+			break;
+	}
+
+	remote_xid = InvalidTransactionId;
+	in_remote_transaction = false;
+
+	elog(DEBUG1, "reset remote_xid %u", remote_xid);
 
 	/*
 	 * Process any tables that are being synchronized in parallel, as well as
@@ -2105,7 +2328,8 @@ apply_handle_prepare(StringInfo s)
 	 * XactLastCommitEnd, and adding it for this purpose doesn't seems worth
 	 * it.
 	 */
-	store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr);
+	store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr,
+						 InvalidTransactionId);
 
 	in_remote_transaction = false;
 
@@ -2165,7 +2389,8 @@ apply_handle_commit_prepared(StringInfo s)
 	CommitTransactionCommand();
 	pgstat_report_stat(false);
 
-	store_flush_position(prepare_data.end_lsn, XactLastCommitEnd);
+	store_flush_position(prepare_data.end_lsn, XactLastCommitEnd,
+						 InvalidTransactionId);
 	in_remote_transaction = false;
 
 	/*
@@ -2234,7 +2459,8 @@ apply_handle_rollback_prepared(StringInfo s)
 	 * transaction because we always flush the WAL record for it. See
 	 * apply_handle_prepare.
 	 */
-	store_flush_position(rollback_data.rollback_end_lsn, InvalidXLogRecPtr);
+	store_flush_position(rollback_data.rollback_end_lsn, InvalidXLogRecPtr,
+						 InvalidTransactionId);
 	in_remote_transaction = false;
 
 	/*
@@ -2296,7 +2522,8 @@ apply_handle_stream_prepare(StringInfo s)
 			 * It is okay not to set the local_end LSN for the prepare because
 			 * we always flush the prepare record. See apply_handle_prepare.
 			 */
-			store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr);
+			store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr,
+								 InvalidTransactionId);
 
 			in_remote_transaction = false;
 
@@ -2488,8 +2715,17 @@ apply_handle_stream_start(StringInfo s)
 
 	set_apply_error_context_xact(stream_xid, InvalidXLogRecPtr);
 
-	/* Try to allocate a worker for the streaming transaction. */
-	if (first_segment)
+	/*
+	 * Try to allocate a worker for the streaming transaction.
+	 *
+	 * TODO: Support assigning streaming transactions to parallel apply workers
+	 * even when non-streaming transactions are running and better dependency
+	 * handling between streaming and non-streaming transactions.
+	 */
+	if (first_segment &&
+		am_leader_apply_worker() &&
+		(!TransactionIdIsValid(last_parallelized_remote_xid) ||
+		 pa_transaction_committed(last_parallelized_remote_xid)))
 		pa_allocate_worker(stream_xid, true);
 
 	apply_action = get_transaction_apply_action(stream_xid, &winfo);
@@ -3004,8 +3240,7 @@ apply_spooled_messages(FileSet *stream_fileset, TransactionId xid,
 	int			fileno;
 	pgoff_t		offset;
 
-	if (!am_parallel_apply_worker())
-		maybe_start_skipping_changes(lsn);
+	maybe_start_skipping_changes(lsn);
 
 	/* Make sure we have an open transaction */
 	begin_replication_step();
@@ -3275,7 +3510,8 @@ apply_handle_commit_internal(LogicalRepCommitData *commit_data)
 
 		pgstat_report_stat(false);
 
-		store_flush_position(commit_data->end_lsn, XactLastCommitEnd);
+		store_flush_position(commit_data->end_lsn, XactLastCommitEnd,
+							 InvalidTransactionId);
 	}
 	else
 	{
@@ -3300,7 +3536,8 @@ apply_handle_relation(StringInfo s)
 {
 	LogicalRepRelation *rel;
 
-	if (handle_streamed_transaction(LOGICAL_REP_MSG_RELATION, s))
+	if (handle_parallelized_transaction(LOGICAL_REP_MSG_RELATION, s) ||
+		handle_streamed_transaction(LOGICAL_REP_MSG_RELATION, s))
 		return;
 
 	rel = logicalrep_read_rel(s);
@@ -3308,6 +3545,8 @@ apply_handle_relation(StringInfo s)
 
 	/* Also reset all entries in the partition map that refer to remoterel. */
 	logicalrep_partmap_reset_relmap(rel);
+
+	pa_distribute_schema_changes_to_workers(rel);
 }
 
 /*
@@ -3323,7 +3562,8 @@ apply_handle_type(StringInfo s)
 {
 	LogicalRepTyp typ;
 
-	if (handle_streamed_transaction(LOGICAL_REP_MSG_TYPE, s))
+	if (handle_parallelized_transaction(LOGICAL_REP_MSG_TYPE, s) ||
+		handle_streamed_transaction(LOGICAL_REP_MSG_TYPE, s))
 		return;
 
 	logicalrep_read_typ(s, &typ);
@@ -3383,6 +3623,7 @@ apply_handle_insert(StringInfo s)
 	 * streamed transactions.
 	 */
 	if (is_skipping_changes() ||
+		handle_parallelized_transaction(LOGICAL_REP_MSG_INSERT, s) ||
 		handle_streamed_transaction(LOGICAL_REP_MSG_INSERT, s))
 		return;
 
@@ -3543,6 +3784,7 @@ apply_handle_update(StringInfo s)
 	 * streamed transactions.
 	 */
 	if (is_skipping_changes() ||
+		handle_parallelized_transaction(LOGICAL_REP_MSG_UPDATE, s) ||
 		handle_streamed_transaction(LOGICAL_REP_MSG_UPDATE, s))
 		return;
 
@@ -3767,6 +4009,7 @@ apply_handle_delete(StringInfo s)
 	 * streamed transactions.
 	 */
 	if (is_skipping_changes() ||
+		handle_parallelized_transaction(LOGICAL_REP_MSG_DELETE, s) ||
 		handle_streamed_transaction(LOGICAL_REP_MSG_DELETE, s))
 		return;
 
@@ -4403,6 +4646,7 @@ apply_handle_truncate(StringInfo s)
 	 * streamed transactions.
 	 */
 	if (is_skipping_changes() ||
+		handle_parallelized_transaction(LOGICAL_REP_MSG_TRUNCATE, s) ||
 		handle_streamed_transaction(LOGICAL_REP_MSG_TRUNCATE, s))
 		return;
 
@@ -4633,6 +4877,10 @@ apply_dispatch(StringInfo s)
  * check which entries on it are already locally flushed. Those we can report
  * as having been flushed.
  *
+ * For non-streaming transactions managed by a parallel apply worker, we will
+ * get the local commit end from the shared parallel apply worker info once the
+ * transaction has been committed by the worker.
+ *
  * The have_pending_txes is true if there are outstanding transactions that
  * need to be flushed.
  */
@@ -4642,6 +4890,7 @@ get_flush_position(XLogRecPtr *write, XLogRecPtr *flush,
 {
 	dlist_mutable_iter iter;
 	XLogRecPtr	local_flush = GetFlushRecPtr(NULL);
+	List	   *committed_pa_xid = NIL;
 
 	*write = InvalidXLogRecPtr;
 	*flush = InvalidXLogRecPtr;
@@ -4651,6 +4900,40 @@ get_flush_position(XLogRecPtr *write, XLogRecPtr *flush,
 		FlushPosition *pos =
 			dlist_container(FlushPosition, node, iter.cur);
 
+		/*
+		 * If the transaction was assigned to a parallel apply worker, attempt
+		 * to retrieve its commit end LSN from that worker.
+		 */
+		if (TransactionIdIsValid(pos->pa_remote_xid) &&
+			!XLogRecPtrIsValid(pos->local_end))
+		{
+			bool		skipped_write;
+
+			pos->local_end = pa_get_last_commit_end(pos->pa_remote_xid, true,
+													&skipped_write);
+
+			elog(DEBUG1,
+				 "got commit end from parallel apply worker, "
+				 "txn: %u, remote_end %X/%X, local_end %X/%X",
+				 pos->pa_remote_xid, LSN_FORMAT_ARGS(pos->remote_end),
+				 LSN_FORMAT_ARGS(pos->local_end));
+
+			/*
+			 * Break the loop if the worker has not finished applying the
+			 * transaction. There's no need to check subsequent transactions,
+			 * as they must commit after the current transaction being
+			 * examined and thus won't have their commit end available yet.
+			 */
+			if (!skipped_write && XLogRecPtrIsInvalid(pos->local_end))
+				break;
+
+			committed_pa_xid = lappend_xid(committed_pa_xid, pos->pa_remote_xid);
+		}
+
+		/*
+		 * Worker has finished applying or the transaction was applied in the
+		 * leader apply worker.
+		 */
 		*write = pos->remote_end;
 
 		if (pos->local_end <= local_flush)
@@ -4659,29 +4942,22 @@ get_flush_position(XLogRecPtr *write, XLogRecPtr *flush,
 			dlist_delete(iter.cur);
 			pfree(pos);
 		}
-		else
-		{
-			/*
-			 * Don't want to uselessly iterate over the rest of the list which
-			 * could potentially be long. Instead get the last element and
-			 * grab the write position from there.
-			 */
-			pos = dlist_tail_element(FlushPosition, node,
-									 &lsn_mapping);
-			*write = pos->remote_end;
-			*have_pending_txes = true;
-			return;
-		}
 	}
 
 	*have_pending_txes = !dlist_is_empty(&lsn_mapping);
+
+	delete_replica_identity_entries_for_txns(committed_pa_xid);
 }
 
 /*
  * Store current remote/local lsn pair in the tracking list.
+ *
+ * pa_remote_xid should be a valid transaction ID only when the transaction was
+ * assigned to a parallel apply worker; otherwise, pass InvalidTransactionId.
  */
 void
-store_flush_position(XLogRecPtr remote_lsn, XLogRecPtr local_lsn)
+store_flush_position(XLogRecPtr remote_lsn, XLogRecPtr local_lsn,
+					 TransactionId pa_remote_xid)
 {
 	FlushPosition *flushpos;
 
@@ -4699,7 +4975,7 @@ store_flush_position(XLogRecPtr remote_lsn, XLogRecPtr local_lsn)
 	flushpos = palloc_object(FlushPosition);
 	flushpos->local_end = local_lsn;
 	flushpos->remote_end = remote_lsn;
-	flushpos->pa_remote_xid = InvalidTransactionId;
+	flushpos->pa_remote_xid = pa_remote_xid;
 
 	dlist_push_tail(&lsn_mapping, &flushpos->node);
 	MemoryContextSwitchTo(ApplyMessageContext);
@@ -6824,6 +7100,22 @@ static void
 maybe_start_skipping_changes(XLogRecPtr finish_lsn)
 {
 	Assert(!is_skipping_changes());
+
+	/*
+	 * For streaming transactions applied in a parallel apply worker, the remote
+	 * end LSN is unknown, so skipping is not possible. For non-streaming
+	 * transactions, the leader determines whether to skip the transaction. If
+	 * skipping is needed, the leader simply does not send the transaction to a
+	 * parallel apply worker.
+	 */
+	if (am_parallel_apply_worker())
+		return;
+
+	/*
+	 * These assertions apply only to leader apply and table sync workers.
+	 * Parallel workers may apply spooled BEGIN or STREAM_START messages, which
+	 * can set these flags to true.
+	 */
 	Assert(!in_remote_transaction);
 	Assert(!in_streamed_transaction);
 
diff --git a/src/include/replication/logicalproto.h b/src/include/replication/logicalproto.h
index cb0a8f440c0..d9f375295de 100644
--- a/src/include/replication/logicalproto.h
+++ b/src/include/replication/logicalproto.h
@@ -259,6 +259,8 @@ extern void logicalrep_write_message(StringInfo out, TransactionId xid, XLogRecP
 extern void logicalrep_write_rel(StringInfo out, TransactionId xid,
 								 Relation rel, Bitmapset *columns,
 								 PublishGencolsType include_gencols_type);
+extern void logicalrep_write_internal_rel(StringInfo out,
+										  LogicalRepRelation *rel);
 extern LogicalRepRelation *logicalrep_read_rel(StringInfo in);
 extern void logicalrep_write_typ(StringInfo out, TransactionId xid,
 								 Oid typoid);
diff --git a/src/include/replication/logicalrelation.h b/src/include/replication/logicalrelation.h
index 09890cef8d4..cd852337165 100644
--- a/src/include/replication/logicalrelation.h
+++ b/src/include/replication/logicalrelation.h
@@ -58,6 +58,8 @@ extern void logicalrep_rel_close(LogicalRepRelMapEntry *rel,
 								 LOCKMODE lockmode);
 extern bool IsIndexUsableForReplicaIdentityFull(Relation idxrel, AttrMap *attrmap);
 extern Oid	GetRelationIdentityOrPK(Relation rel);
+extern int	logicalrep_get_num_rels(void);
+extern void logicalrep_write_all_rels(StringInfo out);
 extern LogicalRepRelMapEntry *logicalrep_get_relentry(LogicalRepRelId remoteid);
 
 #endif							/* LOGICALRELATION_H */
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index 3c88d9e1027..02a165ccc0d 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -345,7 +345,8 @@ extern void SetupApplyOrSyncWorker(int worker_slot);
 
 extern void DisableSubscriptionAndExit(void);
 
-extern void store_flush_position(XLogRecPtr remote_lsn, XLogRecPtr local_lsn);
+extern void store_flush_position(XLogRecPtr remote_lsn, XLogRecPtr local_lsn,
+								 TransactionId pa_remote_xid);
 
 /* Function for apply error callback */
 extern void apply_error_callback(void *arg);
@@ -354,13 +355,15 @@ extern void set_apply_error_context_origin(char *originname);
 /* Parallel apply worker setup and interactions */
 extern void pa_allocate_worker(TransactionId xid, bool stream_txn);
 extern ParallelApplyWorkerInfo *pa_find_worker(TransactionId xid);
-extern XLogRecPtr pa_get_last_commit_end(TransactionId xid, bool *skipped_write);
+extern XLogRecPtr pa_get_last_commit_end(TransactionId xid, bool delete_entry,
+										 bool *skipped_write);
 extern void pa_detach_all_error_mq(void);
 
 extern void apply_handle_internal_message(StringInfo s);
 
 extern bool pa_send_data(ParallelApplyWorkerInfo *winfo, Size nbytes,
 						 const void *data);
+extern void pa_distribute_schema_changes_to_workers(LogicalRepRelation *rel);
 extern void pa_switch_to_partial_serialize(ParallelApplyWorkerInfo *winfo,
 										   bool stream_locked);
 
@@ -386,8 +389,9 @@ extern void pa_decr_and_wait_stream_block(void);
 extern void pa_xact_finish(ParallelApplyWorkerInfo *winfo,
 						   XLogRecPtr remote_lsn);
 extern bool pa_transaction_committed(TransactionId xid);
-
+extern void pa_commit_transaction(void);
 extern void pa_wait_for_depended_transaction(TransactionId xid);
+extern void pa_add_parallelized_transaction(TransactionId xid);
 
 #define isParallelApplyWorker(worker) ((worker)->in_use && \
 									   (worker)->type == WORKERTYPE_PARALLEL_APPLY)
diff --git a/src/test/subscription/meson.build b/src/test/subscription/meson.build
index fdc04d91f41..48ae698e786 100644
--- a/src/test/subscription/meson.build
+++ b/src/test/subscription/meson.build
@@ -48,7 +48,7 @@ tests += {
       't/036_sequences.pl',
       't/037_except.pl',
       't/038_walsnd_shutdown_timeout.pl',
-      't/039_row_filter_toast_ri.pl',
+      't/050_parallel_apply.pl',
       't/100_bugs.pl',
     ],
   },
diff --git a/src/test/subscription/t/001_rep_changes.pl b/src/test/subscription/t/001_rep_changes.pl
index 7d41715ed81..c863b430bec 100644
--- a/src/test/subscription/t/001_rep_changes.pl
+++ b/src/test/subscription/t/001_rep_changes.pl
@@ -16,6 +16,8 @@ $node_publisher->start;
 # Create subscriber node
 my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
 $node_subscriber->init;
+$node_subscriber->append_conf('postgresql.conf',
+	"max_logical_replication_workers = 10");
 $node_subscriber->start;
 
 # Create some preexisting content on publisher
diff --git a/src/test/subscription/t/010_truncate.pl b/src/test/subscription/t/010_truncate.pl
index 945505d0239..e15a6bb2a03 100644
--- a/src/test/subscription/t/010_truncate.pl
+++ b/src/test/subscription/t/010_truncate.pl
@@ -17,7 +17,7 @@ $node_publisher->start;
 my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
 $node_subscriber->init;
 $node_subscriber->append_conf('postgresql.conf',
-	qq(max_logical_replication_workers = 6));
+	qq(max_logical_replication_workers = 7));
 $node_subscriber->start;
 
 my $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
diff --git a/src/test/subscription/t/015_stream.pl b/src/test/subscription/t/015_stream.pl
index ac96bc3f009..705bc83f5ea 100644
--- a/src/test/subscription/t/015_stream.pl
+++ b/src/test/subscription/t/015_stream.pl
@@ -232,6 +232,12 @@ $node_subscriber->wait_for_log(
 
 $node_publisher->safe_psql('postgres', "INSERT INTO test_tab_2 values(1)");
 
+# FIXME: Currently, non-streaming transactions are applied in parallel by
+# default. So, the first transaction is handled by a parallel apply worker. To
+# trigger the deadlock, initiate an more transaction to be applied by the
+# leader.
+$node_publisher->safe_psql('postgres', "INSERT INTO test_tab_2 values(1)");
+
 $h->query_safe('COMMIT');
 $h->quit;
 
@@ -247,7 +253,7 @@ $node_publisher->wait_for_catchup($appname);
 
 $result =
   $node_subscriber->safe_psql('postgres', "SELECT count(*) FROM test_tab_2");
-is($result, qq(5001), 'data replicated to subscriber after dropping index');
+is($result, qq(5002), 'data replicated to subscriber after dropping index');
 
 # Clean up test data from the environment.
 $node_publisher->safe_psql('postgres', "TRUNCATE TABLE test_tab_2");
diff --git a/src/test/subscription/t/026_stats.pl b/src/test/subscription/t/026_stats.pl
index 5d457060a02..911005bde20 100644
--- a/src/test/subscription/t/026_stats.pl
+++ b/src/test/subscription/t/026_stats.pl
@@ -16,6 +16,7 @@ $node_publisher->start;
 # Create subscriber node.
 my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
 $node_subscriber->init;
+$node_subscriber->append_conf('postgresql.conf', "max_logical_replication_workers = 10");
 $node_subscriber->start;
 
 
diff --git a/src/test/subscription/t/027_nosuperuser.pl b/src/test/subscription/t/027_nosuperuser.pl
index 322f5b4cc6a..fdfc44ac729 100644
--- a/src/test/subscription/t/027_nosuperuser.pl
+++ b/src/test/subscription/t/027_nosuperuser.pl
@@ -86,6 +86,7 @@ $node_publisher = PostgreSQL::Test::Cluster->new('publisher');
 $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
 $node_publisher->init(allows_streaming => 'logical');
 $node_subscriber->init;
+$node_subscriber->append_conf('postgresql.conf', "max_logical_replication_workers = 10");
 $node_publisher->start;
 $node_subscriber->start;
 $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
diff --git a/src/test/subscription/t/050_parallel_apply.pl b/src/test/subscription/t/050_parallel_apply.pl
new file mode 100644
index 00000000000..91f95d4e11d
--- /dev/null
+++ b/src/test/subscription/t/050_parallel_apply.pl
@@ -0,0 +1,271 @@
+
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+# This test verifies that a non-streamed transaction can launch a parallel apply
+# worker, and that dependency tracking and commit order preservation work
+# correctly during parallel apply.
+
+use strict;
+use warnings;
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+if ($ENV{enable_injection_points} ne 'yes')
+{
+	plan skip_all => 'Injection points not supported by this build';
+}
+
+# Initialize publisher node
+my $node_publisher = PostgreSQL::Test::Cluster->new('publisher');
+$node_publisher->init(allows_streaming => 'logical');
+$node_publisher->start;
+
+# Create tables and insert initial data
+$node_publisher->safe_psql(
+    'postgres', qq(
+    CREATE TABLE regress_tab (id int PRIMARY KEY, value text);
+
+    CREATE TABLE tab_toast (a text NOT NULL, b text NOT NULL);
+    ALTER TABLE tab_toast ALTER COLUMN a SET STORAGE EXTERNAL;
+    CREATE UNIQUE INDEX tab_toast_ri_index on tab_toast (a, b);
+    ALTER TABLE tab_toast REPLICA IDENTITY USING INDEX tab_toast_ri_index;
+    INSERT INTO tab_toast(a, b) VALUES(repeat('1234567890', 200), '1234567890');
+));
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(1, 10), 'test');");
+
+# Create a publication
+$node_publisher->safe_psql('postgres',
+    "CREATE PUBLICATION regress_pub FOR ALL TABLES;");
+
+# Initialize subscriber node
+my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
+$node_subscriber->init;
+$node_subscriber->append_conf('postgresql.conf', "log_min_messages = debug1");
+$node_subscriber->append_conf('postgresql.conf',
+	"max_logical_replication_workers = 10");
+$node_subscriber->start;
+
+# Check if the extension injection_points is available, as it may be
+# possible that this script is run with installcheck, where the module
+# would not be installed by default.
+if (!$node_subscriber->check_extension('injection_points'))
+{
+	plan skip_all => 'Extension injection_points not installed';
+}
+
+$node_subscriber->safe_psql('postgres', 'CREATE EXTENSION injection_points;');
+
+# Create a subscription
+my $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
+
+$node_subscriber->safe_psql(
+    'postgres', qq(
+    CREATE TABLE regress_tab (id int PRIMARY KEY, value text);
+
+    CREATE TABLE tab_toast (a text NOT NULL, b text NOT NULL);
+    ALTER TABLE tab_toast ALTER COLUMN a SET STORAGE EXTERNAL;
+    CREATE UNIQUE INDEX tab_toast_ri_index on tab_toast (a, b);
+    ALTER TABLE tab_toast REPLICA IDENTITY USING INDEX tab_toast_ri_index;
+));
+$node_subscriber->safe_psql('postgres',
+    "CREATE SUBSCRIPTION regress_sub CONNECTION '$publisher_connstr' PUBLICATION regress_pub;");
+
+# Wait for initial table sync to finish
+$node_subscriber->wait_for_subscription_sync($node_publisher, 'regress_sub');
+
+##################################################
+# Test that a non-streamed transaction can be applied in a parallel apply worker
+##################################################
+
+# Insert tuples on publisher
+#
+# XXX This may not enough to launch a parallel apply worker, because
+# table_states_not_ready is not discarded yet.
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(11, 20), 'test');");
+$node_publisher->wait_for_catchup('regress_sub');
+
+# Insert tuples again
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(21, 30), 'test');");
+$node_publisher->wait_for_catchup('regress_sub');
+
+# Verify the parallel apply worker is launched
+my $result = $node_subscriber->safe_psql('postgres',
+    "SELECT count(1) FROM pg_stat_activity WHERE backend_type = 'logical replication parallel worker'");
+is($result, '1', "parallel apply worker is launched by a non-streamed transaction");
+
+##################################################
+# Test that the basic replica identity dependency tracking and commit order
+# preservation work correctly during parallel apply.
+##################################################
+
+# Attach an injection_point. Parallel workers would wait before the commit
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+# Insert tuples on publisher
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(31, 40), 'test');");
+
+# Wait until the parallel worker enters the injection point.
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+my $offset = -s $node_subscriber->logfile;
+
+# Insert tuples on publisher again. This transaction is independent from the
+# previous one, but the parallel worker would wait till it finishes
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(41, 50), 'test');");
+
+# Verify the parallel worker waits for the transaction
+my $str = $node_subscriber->wait_for_log(qr/wait for depended xid ([1-9][0-9]+)/, $offset);
+my $xid = $str =~ /wait for depended xid ([1-9][0-9]+)/;
+
+ok(1, "commit order dependency detected for parallel apply");
+
+$offset = -s $node_subscriber->logfile;
+
+# Update tuples which have not been applied yet on subscriber because the
+# parallel worker stops at the injection point. Newly assigned worker also
+# waits for the same transactions as above.
+$node_publisher->safe_psql('postgres',
+    "UPDATE regress_tab SET value = 'updated' WHERE id BETWEEN 31 AND 35;");
+
+# Verify the dependency is detected for the update
+$node_subscriber->wait_for_log(qr/found conflicting replica identity change on table [1-9][0-9]+ from $xid/, $offset);
+
+# Verify the parallel worker waits for the same transaction
+$node_subscriber->wait_for_log(qr/wait for depended xid $xid/, $offset);
+
+ok(1, "replica identity dependency detected for parallel apply");
+
+# Wakeup the parallel worker. We detach first no to stop other parallel workers
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+# Verify the parallel worker wakes up
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+
+$node_publisher->wait_for_catchup('regress_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab");
+is ($result, 50, 'inserts are replicated to subscriber');
+
+$result =
+  $node_subscriber->safe_psql('postgres',
+    "SELECT count(1) FROM regress_tab WHERE value = 'updated'");
+is ($result, 5, 'updates are also replicated to subscriber');
+
+##################################################
+# Test that the dependency tracking works correctly for unchanged toasted RI
+# columns.
+##################################################
+
+# Attach an injection_point. Parallel workers would wait before the commit
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+# Update one replica identity column but keep toasted column unchanged
+$node_publisher->safe_psql('postgres',
+    "UPDATE tab_toast SET b = '1';");
+
+# Wait until the parallel worker enters the injection point.
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+$offset = -s $node_subscriber->logfile;
+
+# Delete the updated row.
+$node_publisher->safe_psql('postgres',
+    "DELETE FROM tab_toast WHERE b = '1';");
+
+# Verify the dependency is detected for the delete
+$str = $node_subscriber->wait_for_log(qr/found conflicting replica identity change on table [1-9][0-9]+ from ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /found conflicting replica identity change on table [1-9][0-9]+ from ([1-9][0-9]+)/;
+
+# Verify the parallel worker waits for the same transaction
+$node_subscriber->wait_for_log(qr/wait for depended xid $xid/, $offset);
+
+ok(1, "replica identity dependency from unchanged toasted column detected for parallel apply");
+
+# Wakeup the parallel worker. We detach first no to stop other parallel workers
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+# Verify the parallel worker wakes up
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+
+$node_publisher->wait_for_catchup('regress_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM tab_toast");
+is ($result, 0, 'changes are replicated to subscriber');
+
+##################################################
+# Test that table level dependency tracking by TRUNCATE work correctly during
+# parallel apply.
+##################################################
+
+# Truncate the data for upcoming tests
+$node_publisher->safe_psql('postgres', "TRUNCATE TABLE regress_tab;");
+$node_publisher->wait_for_catchup('regress_sub');
+
+# Attach an injection_point. Parallel workers would wait before the commit
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+# Insert tuples on publisher
+$node_publisher->safe_psql('postgres',
+    "TRUNCATE regress_tab;");
+
+# Wait until the parallel worker enters the injection point.
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+$offset = -s $node_subscriber->logfile;
+
+# Insert a tuple that conflicts with the TRUNCATE operation.
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (1, 'test');");
+
+# Verify the dependency is detected for the insert
+$str = $node_subscriber->wait_for_log(qr/found table-wide change affecting [1-9][0-9]+ from ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /found table-wide change affecting [1-9][0-9]+ from ([1-9][0-9]+)/;
+
+# Verify the parallel worker waits for the same transaction
+$node_subscriber->wait_for_log(qr/wait for depended xid $xid/, $offset);
+
+ok(1, "table-wide dependency from TRUNCATE detected for parallel apply");
+
+# Wakeup the parallel worker. We detach first no to stop other parallel workers
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+# Verify the parallel worker wakes up
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+
+$node_publisher->wait_for_catchup('regress_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab");
+is ($result, 1, 'inserts are replicated to subscriber');
+
+# Change replica identity back to default for upcoming tests
+$node_publisher->safe_psql('postgres',
+    "ALTER TABLE regress_tab REPLICA IDENTITY DEFAULT;");
+
+done_testing();
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 07d246bd957..db8bb4e1e43 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2162,6 +2162,7 @@ ParallelHashGrowth
 ParallelHashJoinBatch
 ParallelHashJoinBatchAccessor
 ParallelHashJoinState
+ParallelizedTxnEntry
 ParallelIndexScanDesc
 ParallelizedTxnEntry
 ParallelSlot
@@ -4257,6 +4258,7 @@ rendezvousHashEntry
 rep
 replace_rte_variables_callback
 replace_rte_variables_context
+replica_identity_hash
 report_error_fn
 ret_type
 rewind_source
-- 
2.43.0



  [application/octet-stream] v20-0003-Introduce-a-local-hash-table-to-store-replica-id.patch (35.4K, ../../TY4PR01MB1771875A7A64B139EF8C8D05C94E62@TY4PR01MB17718.jpnprd01.prod.outlook.com/9-v20-0003-Introduce-a-local-hash-table-to-store-replica-id.patch)
  download | inline diff:
From 11bb592033c5db1b56369395a103a2296042dafd Mon Sep 17 00:00:00 2001
From: Zhijie Hou <[email protected]>
Date: Mon, 15 Jun 2026 18:51:14 +0800
Subject: [PATCH v20 3/4] Introduce a local hash table to store replica
 identities

This local hash table on the leader is used for detecting dependencies between
transactions.

The hash contains the Replica Identity (RI) as a key and the remote XID that
modified the corresponding tuple. The hash entries are inserted when the leader
finds an RI from a replication message, and are removed in the
following cases:

1) When handling a subsequent change that modifies the same row, and the
   stored transaction has already committed.
2) When collecting flush progress of remote transactions, and the stored
   transaction is found to have committed.
3) When the number of entries exceeds REPLICA_IDENTITY_CLEANUP_THRESHOLD
   and the stored transaction has already committed.

When the leader sends replication changes to parallel workers, it checks whether
aother transaction has already used the RI associated with the change. If so,
the leader marks the current transaction as dependent on that transaction and
notifies parallel workers via PA_MSG_XACT_DEPENDENCY to wait for that
transaction to finish.

Author: Zhijie Hou <[email protected]>
Author: Hayato Kuroda <[email protected]>
---
 .../replication/logical/applyparallelworker.c | 131 +++-
 src/backend/replication/logical/relation.c    |  24 +
 src/backend/replication/logical/worker.c      | 722 +++++++++++++++++-
 src/include/replication/logicalrelation.h     |   9 +
 src/include/replication/worker_internal.h     |   6 +-
 src/test/subscription/meson.build             |   1 +
 src/tools/pgindent/typedefs.list              |   3 +
 7 files changed, 891 insertions(+), 5 deletions(-)

diff --git a/src/backend/replication/logical/applyparallelworker.c b/src/backend/replication/logical/applyparallelworker.c
index efde9181a34..30d31e02ac1 100644
--- a/src/backend/replication/logical/applyparallelworker.c
+++ b/src/backend/replication/logical/applyparallelworker.c
@@ -213,17 +213,41 @@
 #define PARALLEL_APPLY_LOCK_XACT	1
 
 /*
- * Hash table entry to map xid to the parallel apply worker state.
+ * Hash table entry of ParallelApplyTxnHash to map xid to the parallel apply
+ * worker state.
  */
 typedef struct ParallelApplyWorkerEntry
 {
-	TransactionId xid;			/* Hash key -- must be first */
+	TransactionId xid;			/* Remote transaction ID (hash key) ---
+								 * must be first */
+
+	/*
+	 * The parallel apply worker assigned for applying the transaction. It is
+	 * NULL if the worker has already finished the transaction and the leader
+	 * has collected its local end LSN.
+	 */
 	ParallelApplyWorkerInfo *winfo;
+
+	/*
+	 * The local end LSN of this transaction applied by the parallel apply
+	 * worker.
+	 *
+	 * The leader will initialize this value by reading the last_commit_end from
+	 * the parallel apply worker shared memory before reusing the worker for
+	 * another transaction (see pa_get_last_commit_end). An invalid value
+	 * indicates the worker has not finished applying the transaction or there
+	 * is no change applied by the worker for this transaction.
+	 */
+	XLogRecPtr	local_end;
 } ParallelApplyWorkerEntry;
 
 /*
  * A hash table used to cache the state of streaming transactions being applied
  * by the parallel apply workers. Entries are of type ParallelApplyWorkerEntry.
+ *
+ * The leader apply worker adds an entry when a transaction starts being
+ * applied, and removes it after collecting the transaction's local end LSN from
+ * the parallel worker.
  */
 static HTAB *ParallelApplyTxnHash = NULL;
 
@@ -237,6 +261,10 @@ typedef struct ParallelizedTxnEntry
  * A hash table used to track the parallelized remote transactions that could be
  * depended on by other transactions. Entries are of type ParallelizedTxnEntry.
  *
+ * The leader apply worker adds an entry before sending the COMMIT record of a
+ * transaction to a parallel apply worker. The entry is removed by that worker
+ * immediately after committing the transaction.
+ *
  * dshash is used to enable dynamic shared memory allocation based on the number
  * of transactions being applied in parallel.
  */
@@ -510,7 +538,7 @@ pa_launch_parallel_worker(void)
  * streaming changes.
  */
 void
-pa_allocate_worker(TransactionId xid)
+pa_allocate_worker(TransactionId xid, bool stream_txn)
 {
 	bool		found;
 	ParallelApplyWorkerInfo *winfo = NULL;
@@ -551,7 +579,9 @@ pa_allocate_worker(TransactionId xid)
 
 	winfo->in_use = true;
 	winfo->serialize_changes = false;
+	winfo->stream_txn = stream_txn;
 	entry->winfo = winfo;
+	entry->local_end = InvalidXLogRecPtr;
 }
 
 /*
@@ -748,6 +778,72 @@ pa_process_spooled_messages_if_required(void)
 	return true;
 }
 
+/*
+ * Get the local end LSN for a transaction applied by a parallel apply worker.
+ *
+ * If the parallel apply worker did not write any changes during the transaction
+ * application due to situations like update/delete_missing or a before trigger,
+ * the *skipped_write will be set to true.
+ *
+ * Once the LSN is retrieved, the transaction entry is removed from
+ * ParallelApplyTxnHash.
+ */
+XLogRecPtr
+pa_get_last_commit_end(TransactionId xid, bool *skipped_write)
+{
+	bool		found;
+	ParallelApplyWorkerEntry *entry;
+	ParallelApplyWorkerInfo *winfo;
+
+	Assert(TransactionIdIsValid(xid));
+	Assert(ParallelApplyTxnHash);
+
+	if (skipped_write)
+		*skipped_write = false;
+
+	/* Find an entry for the requested transaction. */
+	entry = hash_search(ParallelApplyTxnHash, &xid, HASH_FIND, &found);
+
+	if (!found)
+		return InvalidXLogRecPtr;
+
+	/*
+	 * If worker info is NULL, it indicates that the worker has already
+	 * finished handling the transaction. Consequently, the local end LSN has
+	 * already been collected and saved in entry->local_end.
+	 */
+	winfo = entry->winfo;
+	if (winfo == NULL)
+	{
+		if (!hash_search(ParallelApplyTxnHash, &xid, HASH_REMOVE, NULL))
+			elog(ERROR, "hash table corrupted");
+
+		if (skipped_write)
+			*skipped_write = XLogRecPtrIsInvalid(entry->local_end);
+
+		return entry->local_end;
+	}
+
+	/* Return InvalidXLogRecPtr if the transaction is still in progress */
+	if (pa_get_xact_state(winfo->shared) != PARALLEL_TRANS_FINISHED)
+		return InvalidXLogRecPtr;
+
+	/* Collect the local end LSN from the worker's shared memory area */
+	entry->local_end = winfo->shared->last_commit_end;
+	entry->winfo = NULL;
+
+	if (skipped_write)
+		*skipped_write = XLogRecPtrIsInvalid(entry->local_end);
+
+	elog(DEBUG1, "store local commit %X/%X end to txn entry: %u",
+		 LSN_FORMAT_ARGS(entry->local_end), xid);
+
+	if (!hash_search(ParallelApplyTxnHash, &xid, HASH_REMOVE, NULL))
+		elog(ERROR, "hash table corrupted");
+
+	return entry->local_end;
+}
+
 /*
  * Interrupt handler for main loop of parallel apply worker.
  */
@@ -1795,6 +1891,35 @@ pa_xact_finish(ParallelApplyWorkerInfo *winfo, XLogRecPtr remote_lsn)
 	pa_free_worker(winfo);
 }
 
+/*
+ * Check if the given transaction has been committed. This can only be used by
+ * the leader apply worker.
+ *
+ * Returns true if the transaction is committed, false if it is still being
+ * applied in parallel.
+ */
+bool
+pa_transaction_committed(TransactionId xid)
+{
+	bool		found;
+	ParallelApplyWorkerEntry *entry;
+
+	Assert(am_leader_apply_worker());
+	Assert(TransactionIdIsValid(xid));
+	Assert(ParallelApplyTxnHash);
+
+	/* Find an entry for the requested transaction */
+	entry = hash_search(ParallelApplyTxnHash, &xid, HASH_FIND, &found);
+
+	if (!found)
+		return true;
+
+	if (!entry->winfo)
+		return true;
+
+	return pa_get_xact_state(entry->winfo->shared) == PARALLEL_TRANS_FINISHED;
+}
+
 /*
  * Attach to the shared hash table for parallelized transactions.
  */
diff --git a/src/backend/replication/logical/relation.c b/src/backend/replication/logical/relation.c
index 0b1d80b5b0f..ab6313eb1bc 100644
--- a/src/backend/replication/logical/relation.c
+++ b/src/backend/replication/logical/relation.c
@@ -960,3 +960,27 @@ FindLogicalRepLocalIndex(Relation localrel, LogicalRepRelation *remoterel,
 
 	return InvalidOid;
 }
+
+/*
+ * Get the LogicalRepRelMapEntry corresponding to the given relid without
+ * opening the local relation.
+ */
+LogicalRepRelMapEntry *
+logicalrep_get_relentry(LogicalRepRelId remoteid)
+{
+	LogicalRepRelMapEntry *entry;
+	bool		found;
+
+	if (LogicalRepRelMap == NULL)
+		logicalrep_relmap_init();
+
+	/* Search for existing entry. */
+	entry = hash_search(LogicalRepRelMap, (void *) &remoteid,
+						HASH_FIND, &found);
+
+	if (!found)
+		elog(DEBUG1, "no relation map entry for remote relation ID %u",
+			 remoteid);
+
+	return entry;
+}
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 6599d563306..19cfbcb1cb7 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -303,11 +303,35 @@
 
 #define NAPTIME_PER_CYCLE 1000	/* max sleep time between cycles (1s) */
 
+/*
+ * Struct for tracking the progress of flushing the transaction received from
+ * the publisher.
+ *
+ * A list of these structs (lsn_mapping) is maintained in the apply worker, each
+ * representing a transaction that is being flushed. The entries are removed
+ * from the list when the transaction is fully flushed (see get_flush_position).
+ */
 typedef struct FlushPosition
 {
 	dlist_node	node;
+
+	/*
+	 * The end of commit record applied by the worker, and the corresponding end
+	 * of commit record on the publisher.
+	 *
+	 * For transactions assigned to a parallel apply worker, the local_end is
+	 * not immediately available. The leader will update the local_end when it
+	 * confirms that the parallel apply worker has finished applying the
+	 * transaction (see the usage of pa_get_last_commit_end).
+	 */
 	XLogRecPtr	local_end;
 	XLogRecPtr	remote_end;
+
+	/*
+	 * The remote transaction ID. This should be set to a valid ID only when the
+	 * transaction is assigned to a parallel apply worker.
+	 */
+	TransactionId pa_remote_xid;
 } FlushPosition;
 
 static dlist_head lsn_mapping = DLIST_STATIC_INIT(lsn_mapping);
@@ -476,6 +500,8 @@ ErrorContextCallback *apply_error_context_stack = NULL;
 MemoryContext ApplyMessageContext = NULL;
 MemoryContext ApplyContext = NULL;
 
+static MemoryContext ParallelApplyContext = NULL;
+
 /* per stream context for streaming transactions */
 static MemoryContext LogicalStreamingContext = NULL;
 
@@ -549,6 +575,65 @@ typedef struct ApplySubXactData
 
 static ApplySubXactData subxact_data = {0, 0, InvalidTransactionId, NULL};
 
+/* Hash table key for replica_identity_table */
+typedef struct ReplicaIdentityKey
+{
+	Oid			relid;
+	LogicalRepTupleData *data;
+} ReplicaIdentityKey;
+
+/* Hash table entry in replica_identity_table */
+typedef struct ReplicaIdentityEntry
+{
+	ReplicaIdentityKey *keydata;
+	TransactionId remote_xid;
+
+	/* needed for simplehash */
+	uint32		hash;
+	char		status;
+} ReplicaIdentityEntry;
+
+#include "common/hashfn.h"
+
+static uint32 hash_replica_identity(ReplicaIdentityKey *key);
+static bool hash_replica_identity_compare(ReplicaIdentityKey *a,
+										  ReplicaIdentityKey *b);
+
+/* Define parameters for replica identity hash table code generation. */
+#define SH_PREFIX		replica_identity
+#define SH_ELEMENT_TYPE	ReplicaIdentityEntry
+#define SH_KEY_TYPE		ReplicaIdentityKey *
+#define	SH_KEY			keydata
+#define SH_HASH_KEY(tb, key)	hash_replica_identity(key)
+#define SH_EQUAL(tb, a, b)		hash_replica_identity_compare(a, b)
+#define SH_STORE_HASH
+#define SH_GET_HASH(tb, a) (a)->hash
+#define	SH_SCOPE		static inline
+#define SH_DECLARE
+#define SH_DEFINE
+#include "lib/simplehash.h"
+
+#define REPLICA_IDENTITY_INITIAL_SIZE 128
+#define REPLICA_IDENTITY_CLEANUP_THRESHOLD 1024
+
+/*
+ * Hash table storing replica identity values for changes being applied in
+ * parallel, along with the latest transaction that modified each row.
+ *
+ * Entries are removed in the following cases:
+ *
+ * 1) When handling a subsequent change that modifies the same row, and the
+ *    stored transaction has already committed.
+ * 2) When collecting flush progress of remote transactions, and the stored
+ *    transaction is found to have committed.
+ * 3) When the number of entries exceeds REPLICA_IDENTITY_CLEANUP_THRESHOLD
+ *    and the stored transaction has already committed.
+ *
+ * We use simplehash for its efficiency (see comments atop of simplehash.h), as
+ * this hash table is accessed for every change in a transaction.
+ */
+static replica_identity_hash *replica_identity_table = NULL;
+
 static inline void subxact_filename(char *path, Oid subid, TransactionId xid);
 static inline void changes_filename(char *path, Oid subid, TransactionId xid);
 
@@ -636,6 +721,614 @@ static void set_wal_receiver_timeout(void);
 
 static void on_exit_clear_xact_state(int code, Datum arg);
 
+static void send_internal_dependencies(ParallelApplyWorkerInfo *winfo,
+									   List *depends_on_xids);
+
+/*
+ * Compute the hash value for entries in the replica_identity_table.
+ */
+static uint32
+hash_replica_identity(ReplicaIdentityKey *key)
+{
+	int			i;
+	uint32		hashkey = 0;
+
+	hashkey = hash_combine(hashkey, hash_uint32(key->relid));
+
+	for (i = 0; i < key->data->ncols; i++)
+	{
+		uint32		hkey;
+
+		if (key->data->colstatus[i] == LOGICALREP_COLUMN_NULL)
+			continue;
+
+		hkey = hash_any((const unsigned char *) key->data->colvalues[i].data,
+						key->data->colvalues[i].len);
+		hashkey = hash_combine(hashkey, hkey);
+	}
+
+	return hashkey;
+}
+
+/*
+ * Compare two entries in the replica_identity_table.
+ */
+static bool
+hash_replica_identity_compare(ReplicaIdentityKey *a, ReplicaIdentityKey *b)
+{
+	if (a->relid != b->relid ||
+		a->data->ncols != b->data->ncols)
+		return false;
+
+	for (int i = 0; i < a->data->ncols; i++)
+	{
+		if (a->data->colstatus[i] != b->data->colstatus[i])
+			return false;
+
+		if (a->data->colvalues[i].len != b->data->colvalues[i].len)
+			return false;
+
+		if (strcmp(a->data->colvalues[i].data, b->data->colvalues[i].data))
+			return false;
+
+		elog(DEBUG1, "conflicting key %s", a->data->colvalues[i].data);
+	}
+
+	return true;
+}
+
+/*
+ * Free resources associated with a replica identity key.
+ */
+static void
+free_replica_identity_key(ReplicaIdentityKey *key)
+{
+	Assert(key);
+
+	pfree(key->data->colvalues);
+	pfree(key->data->colstatus);
+	pfree(key->data);
+	pfree(key);
+}
+
+/*
+ * Clean up hash table entries associated with the given transaction IDs.
+ */
+static void
+delete_replica_identity_entries_for_txns(List *committed_xids)
+{
+	replica_identity_iterator i;
+	ReplicaIdentityEntry *rientry;
+
+	if (!committed_xids)
+		return;
+
+	replica_identity_start_iterate(replica_identity_table, &i);
+	while ((rientry = replica_identity_iterate(replica_identity_table, &i)) != NULL)
+	{
+		if (!list_member_xid(committed_xids, rientry->remote_xid))
+			continue;
+
+		/* Clean up the hash entry for committed transaction */
+		free_replica_identity_key(rientry->keydata);
+		replica_identity_delete_item(replica_identity_table, rientry);
+	}
+}
+
+/*
+ * Check committed transactions and clean up corresponding entries in the hash
+ * table.
+ *
+ * Also update the local_end for transactions assigned to parallel apply workers
+ * if not already set.
+ */
+static void
+cleanup_committed_replica_identity_entries(void)
+{
+	dlist_mutable_iter iter;
+	List	   *committed_xids = NIL;
+
+	dlist_foreach_modify(iter, &lsn_mapping)
+	{
+		FlushPosition *pos =
+			dlist_container(FlushPosition, node, iter.cur);
+		bool		skipped_write;
+
+		/*
+		 * Skip if the transaction was not assigned to a parallel worker, or if
+		 * we have already collected its local end LSN.
+		 */
+		if (!TransactionIdIsValid(pos->pa_remote_xid) ||
+			XLogRecPtrIsValid(pos->local_end))
+			continue;
+
+		pos->local_end = pa_get_last_commit_end(pos->pa_remote_xid,
+												&skipped_write);
+
+		elog(DEBUG1,
+			 "got commit end from parallel apply worker, "
+			 "txn: %u, remote_end %X/%X, local_end %X/%X",
+			 pos->pa_remote_xid, LSN_FORMAT_ARGS(pos->remote_end),
+			 LSN_FORMAT_ARGS(pos->local_end));
+
+		/* Skip if the transaction is still being applied */
+		if (!skipped_write && !XLogRecPtrIsValid(pos->local_end))
+			continue;
+
+		committed_xids = lappend_xid(committed_xids, pos->pa_remote_xid);
+	}
+
+	/* cleanup the entries for committed transactions */
+	delete_replica_identity_entries_for_txns(committed_xids);
+}
+
+/*
+ * Check whether the given relation entry contains an uncommitted transaction
+ * that affected the whole table.
+ */
+static bool
+has_active_rel_dependency(LogicalRepRelMapEntry *relentry)
+{
+	if (!TransactionIdIsValid(relentry->last_depended_xid))
+		return false;
+
+	if (pa_transaction_committed(relentry->last_depended_xid))
+	{
+		relentry->last_depended_xid = InvalidTransactionId;
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * Check whether the given key dependency entry contains an uncommitted
+ * transaction. If cleanup is true, also clean up the entry if the transaction
+ * has already committed.
+ */
+static bool
+has_active_key_dependency(ReplicaIdentityEntry *rientry, bool cleanup)
+{
+	Assert(TransactionIdIsValid(rientry->remote_xid));
+
+	if (pa_transaction_committed(rientry->remote_xid))
+	{
+		if (cleanup)
+		{
+			free_replica_identity_key(rientry->keydata);
+			replica_identity_delete_item(replica_identity_table, rientry);
+		}
+
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * Append the given transaction ID to the dependency list if it is not already
+ * present.
+ */
+static void
+append_xid_dependency(TransactionId xid, List **depends_on_xids)
+{
+	Assert(TransactionIdIsValid(xid));
+
+	if (list_member_xid(*depends_on_xids, xid))
+		return;
+
+	*depends_on_xids = lappend_xid(*depends_on_xids, xid);
+}
+
+/*
+ * Check for dependencies on preceding transactions that modify the same key as
+ * the given tuple. Returns the dependent transactions in 'depends_on_xids'.
+ *
+ * Additionally, if new_depended_xid is valid, record the current change and the
+ * transaction as a new dependency for the replica identity key modification,
+ * allowing subsequent transactions that modify the same key to be dependent on
+ * it.
+ */
+static void
+check_and_record_ri_dependency(Oid relid, LogicalRepTupleData *original_data,
+							   TransactionId new_depended_xid,
+							   List **depends_on_xids)
+{
+	LogicalRepRelMapEntry *relentry;
+	LogicalRepTupleData *ridata;
+	ReplicaIdentityKey *rikey;
+	ReplicaIdentityEntry *rientry;
+	MemoryContext oldctx;
+	int			n_ri;
+	bool		found = false;
+
+	Assert(depends_on_xids);
+
+	/* Search for existing entry */
+	relentry = logicalrep_get_relentry(relid);
+
+	Assert(relentry);
+
+	/*
+	 * First check whether any previous transaction (other than the current one)
+	 * has affected the whole table e.g., truncate or schema change from
+	 * publisher.
+	 */
+	if (has_active_rel_dependency(relentry) &&
+		!TransactionIdEquals(relentry->last_depended_xid, new_depended_xid))
+	{
+		elog(DEBUG1, "found table-wide change affecting %u from %u",
+			 relid, relentry->last_depended_xid);
+
+		append_xid_dependency(relentry->last_depended_xid, depends_on_xids);
+	}
+
+	n_ri = bms_num_members(relentry->remoterel.attkeys);
+
+	/*
+	 * Return if there are no replica identity columns, indicating that the
+	 * remote relation has neither a replica identity key nor is marked as
+	 * replica identity full.
+	 */
+	if (!n_ri)
+		return;
+
+	/* Check if the RI key value of the tuple is invalid */
+	for (int i = 0; i < original_data->ncols; i++)
+	{
+		if (!bms_is_member(i, relentry->remoterel.attkeys))
+			continue;
+
+		/*
+		 * NULL in the new tuple means the replica identity key hasn't changed,
+		 * so no new dependency needs to be recorded. The dependency should have
+		 * been recorded when processing the old tuple.
+		 */
+		if (original_data->colstatus[i] == LOGICALREP_COLUMN_NULL)
+			return;
+
+		/*
+		 * LOGICALREP_COLUMN_UNCHANGED only indicates that a TOAST column in the
+		 * replica identity key hasn't changed. However, other columns may have
+		 * changed, so we still need to check the dependency for this column.
+		 *
+		 * Before calling this function, unchanged TOAST column values should
+		 * have been copied from the old tuple to the new tuple. So, we should
+		 * see the complete replica identity key value in original_data and
+		 * correctly check the dependency.
+		 */
+		Assert(original_data->colstatus[i] != LOGICALREP_COLUMN_UNCHANGED ||
+			   original_data->colvalues[i].len > 0);
+	}
+
+	oldctx = MemoryContextSwitchTo(ApplyContext);
+
+	/* Allocate space for replica identity values */
+	ridata = palloc0_object(LogicalRepTupleData);
+	ridata->colvalues = palloc0_array(StringInfoData, n_ri);
+	ridata->colstatus = palloc0_array(char, n_ri);
+	ridata->ncols = n_ri;
+
+	for (int i_original = 0, i_ri = 0; i_original < original_data->ncols; i_original++)
+	{
+		StringInfo	original_colvalue = &original_data->colvalues[i_original];
+
+		if (!bms_is_member(i_original, relentry->remoterel.attkeys))
+			continue;
+
+		initStringInfoExt(&ridata->colvalues[i_ri], original_colvalue->len + 1);
+		appendStringInfoString(&ridata->colvalues[i_ri], original_colvalue->data);
+		ridata->colstatus[i_ri] = original_data->colstatus[i_original];
+		i_ri++;
+	}
+
+	rikey = palloc0_object(ReplicaIdentityKey);
+	rikey->relid = relid;
+	rikey->data = ridata;
+
+	MemoryContextSwitchTo(oldctx);
+
+	/*
+	 * The new xid could be invalid if the transaction will be applied by the
+	 * leader itself which means all the changes will be committed before
+	 * processing next transaction. In this case, we only need to check for
+	 * dependencies on preceding transactions, there is no need to record a new
+	 * dependency for subsequent transactions to wait on.
+	 */
+	if (!TransactionIdIsValid(new_depended_xid))
+	{
+		rientry = replica_identity_lookup(replica_identity_table, rikey);
+		free_replica_identity_key(rikey);
+
+		if (rientry && has_active_key_dependency(rientry, true))
+		{
+			elog(DEBUG1, "found conflicting replica identity change on table %u from %u",
+				 relid, rientry->remote_xid);
+
+			append_xid_dependency(rientry->remote_xid, depends_on_xids);
+		}
+
+		return;
+	}
+
+	/* Record a new dependency for subsequent transactions to wait on */
+	rientry = replica_identity_insert(replica_identity_table, rikey,
+									  &found);
+
+	/*
+	 * Release the key built to search the entry, if the entry already exists.
+	 */
+	if (found)
+	{
+		free_replica_identity_key(rikey);
+
+		/*
+		 * Append the dependency to the list if the current transaction was not
+		 * the lastest one to modify the key.
+		 */
+		if (has_active_key_dependency(rientry, false) &&
+			!TransactionIdEquals(rientry->remote_xid, new_depended_xid))
+		{
+			elog(DEBUG1, "found conflicting replica identity change on table %u from %u",
+				 relid, rientry->remote_xid);
+
+			append_xid_dependency(rientry->remote_xid, depends_on_xids);
+		}
+	}
+
+	/* Update the new depended xid into the entry */
+	rientry->remote_xid = new_depended_xid;
+}
+
+/*
+ * Check for preceding transactions (other than 'current_xid') that involve
+ * insert, delete, or update operations on the specified table, and return them
+ * in 'depends_on_xids'.
+ */
+static void
+find_all_dependencies_on_rel(LogicalRepRelId relid,
+							 TransactionId new_depended_xid,
+							 List **depends_on_xids)
+{
+	replica_identity_iterator i;
+	ReplicaIdentityEntry *rientry;
+
+	Assert(depends_on_xids);
+
+	replica_identity_start_iterate(replica_identity_table, &i);
+	while ((rientry = replica_identity_iterate(replica_identity_table, &i)) != NULL)
+	{
+		Assert(TransactionIdIsValid(rientry->remote_xid));
+
+		if (rientry->keydata->relid != relid)
+			continue;
+
+		/* Skip entries that do not have a valid and uncommitted transaction */
+		if (!has_active_key_dependency(rientry, true))
+			continue;
+
+		/* Skip self-dependency */
+		if (TransactionIdEquals(rientry->remote_xid, new_depended_xid))
+			continue;
+
+		elog(DEBUG1, "found conflicting change on table %u from %u",
+			 relid, rientry->remote_xid);
+
+		append_xid_dependency(rientry->remote_xid, depends_on_xids);
+	}
+}
+
+/*
+ * Check for any preceding transactions that affect the given table and returns
+ * them in 'depends_on_xids'.
+ *
+ * Additionally, if new_depended_xid is valid, record the current change and its
+ * transaction as a new table-level dependency, allowing subsequent transactions
+ * that modify the same table to be dependent on it.
+ */
+static void
+check_and_record_rel_dependency(LogicalRepRelId relid,
+								TransactionId new_depended_xid,
+								List **depends_on_xids)
+{
+	LogicalRepRelMapEntry *relentry;
+
+	Assert(depends_on_xids);
+
+	find_all_dependencies_on_rel(relid, new_depended_xid, depends_on_xids);
+
+	/* Search for existing entry */
+	relentry = logicalrep_get_relentry(relid);
+
+	/* First time seeing this relation, no entry exists. */
+	if (!relentry)
+		return;
+
+	/*
+	 * Check whether any previous transaction (other than the current one) has
+	 * affected the whole table e.g., truncate or schema change from publisher.
+	 */
+	if (has_active_rel_dependency(relentry) &&
+		!TransactionIdEquals(relentry->last_depended_xid, new_depended_xid))
+	{
+		elog(DEBUG1, "found table-wide change affecting %u from %u",
+			 relid, relentry->last_depended_xid);
+
+		append_xid_dependency(relentry->last_depended_xid, depends_on_xids);
+	}
+
+	if (TransactionIdIsValid(new_depended_xid))
+		relentry->last_depended_xid = new_depended_xid;
+}
+
+/*
+ * Check dependencies related to the current change by determining if the
+ * modification impacts the same row or table as another ongoing transaction.
+ *
+ * If a dependency on preceding transactions is found, this function instructs
+ * the appropriate worker (parallel apply or leader) to wait for those
+ * transactions to complete.
+ *
+ * Simultaneously, if the current change will be dispatched to a parallel apply
+ * worker (indicated by a valid new_depended_xid and a non-NULL winfo), it
+ * records a new dependency so that subsequent transactions will wait on it.
+ */
+static void
+handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
+							TransactionId new_depended_xid,
+							ParallelApplyWorkerInfo *winfo)
+{
+	LogicalRepRelId relid;
+	LogicalRepTupleData oldtup;
+	LogicalRepTupleData newtup;
+	LogicalRepRelation *rel;
+	List	   *depends_on_xids = NIL;
+	List	   *remote_relids;
+	bool		has_oldtup = false;
+	bool		cascade = false;
+	bool		restart_seqs = false;
+
+	/*
+	 * Parse the consume data using a local copy instead of directly consuming
+	 * the given remote change as the caller may also read the data from the
+	 * remote message.
+	 */
+	StringInfoData change = *s;
+
+	/* Only the leader checks dependencies and schedules the parallel apply */
+	if (!am_leader_apply_worker())
+		return;
+
+	if (!ParallelApplyContext)
+	{
+		Assert(!replica_identity_table);
+
+		/*
+		 * Create a permanent memory context for dependency information that
+		 * persists across transactions. Using a dedicated context makes memory
+		 * consumption more visible and easier to track, especially when
+		 * handling a large number of change entries for transactions being
+		 * applied in parallel.
+		 */
+		ParallelApplyContext = AllocSetContextCreate(ApplyContext,
+													 "ParallelApplyContext",
+													 ALLOCSET_DEFAULT_SIZES);
+
+		replica_identity_table = replica_identity_create(ParallelApplyContext,
+														 REPLICA_IDENTITY_INITIAL_SIZE,
+														 NULL);
+	}
+
+	if (replica_identity_table->members >= REPLICA_IDENTITY_CLEANUP_THRESHOLD)
+		cleanup_committed_replica_identity_entries();
+
+	switch (action)
+	{
+		case LOGICAL_REP_MSG_INSERT:
+			relid = logicalrep_read_insert(&change, &newtup);
+			check_and_record_ri_dependency(relid, &newtup, new_depended_xid,
+										   &depends_on_xids);
+			break;
+
+		case LOGICAL_REP_MSG_UPDATE:
+			relid = logicalrep_read_update(&change, &has_oldtup, &oldtup,
+										   &newtup);
+
+			if (has_oldtup)
+			{
+				check_and_record_ri_dependency(relid, &oldtup, new_depended_xid,
+											   &depends_on_xids);
+
+				/*
+				 * Copy unchanged column values from the old tuple to the new
+				 * tuple for replica identity dependency checking. See
+				 * check_and_record_ri_dependency() for details. Also adjust the
+				 * column status so that hash comparisons match correctly.
+				 */
+				for (int i = 0; i < oldtup.ncols; i++)
+				{
+					if (newtup.colstatus[i] == LOGICALREP_COLUMN_UNCHANGED)
+					{
+						newtup.colvalues[i] = oldtup.colvalues[i];
+						newtup.colstatus[i] = oldtup.colstatus[i];
+					}
+				}
+			}
+
+			check_and_record_ri_dependency(relid, &newtup, new_depended_xid,
+										   &depends_on_xids);
+			break;
+
+		case LOGICAL_REP_MSG_DELETE:
+			relid = logicalrep_read_delete(&change, &oldtup);
+			check_and_record_ri_dependency(relid, &oldtup, new_depended_xid,
+										   &depends_on_xids);
+			break;
+
+		case LOGICAL_REP_MSG_TRUNCATE:
+			remote_relids = logicalrep_read_truncate(&change, &cascade,
+													 &restart_seqs);
+
+			/*
+			 * Truncate affects all rows in a table, so the current
+			 * transaction should wait for all preceding transactions that
+			 * modified the same table.
+			 */
+			foreach_int(truncated_relid, remote_relids)
+				check_and_record_rel_dependency(truncated_relid,
+												new_depended_xid,
+												&depends_on_xids);
+
+			break;
+
+		case LOGICAL_REP_MSG_RELATION:
+			rel = logicalrep_read_rel(&change);
+
+			/*
+			 * The replica identity key could be changed, making existing
+			 * entries in the replica identity invalid. In this case, parallel
+			 * apply is not allowed on this specific table until all running
+			 * transactions that modified it have finished.
+			 */
+			check_and_record_rel_dependency(rel->remoteid, new_depended_xid,
+											&depends_on_xids);
+			break;
+
+		case LOGICAL_REP_MSG_TYPE:
+		case LOGICAL_REP_MSG_MESSAGE:
+
+			/*
+			 * Type updates accompany relation updates, so dependencies have
+			 * already been checked during relation updates. Logical messages
+			 * do not conflict with any changes, so they can be ignored.
+			 */
+			break;
+
+		default:
+			Assert(false);
+			break;
+	}
+
+	/* Return early if the current change has no dependencies. */
+	if (!depends_on_xids)
+		return;
+
+	/*
+	 * If the leader applies the transaction itself, start waiting for
+	 * transactions that the current change depends on to finish. Otherwise,
+	 * instruct the parallel apply worker to wait for them.
+	 */
+	if (winfo == NULL)
+	{
+		foreach_xid(xid, depends_on_xids)
+			pa_wait_for_depended_transaction(xid);
+	}
+	else
+	{
+		send_internal_dependencies(winfo, depends_on_xids);
+	}
+}
+
 /*
  * Form the origin name for the subscription.
  *
@@ -792,7 +1485,10 @@ handle_streamed_transaction(LogicalRepMsgType action, StringInfo s)
 
 	/* not in streaming mode */
 	if (apply_action == TRANS_LEADER_APPLY)
+	{
+		handle_dependency_on_change(action, s, InvalidTransactionId, winfo);
 		return false;
+	}
 
 	Assert(TransactionIdIsValid(stream_xid));
 
@@ -1244,6 +1940,29 @@ apply_handle_begin(StringInfo s)
 	pgstat_report_activity(STATE_RUNNING, NULL);
 }
 
+/*
+ * Send an INTERNAL_DEPENDENCY message to a parallel apply worker.
+ */
+static void
+send_internal_dependencies(ParallelApplyWorkerInfo *winfo, List *depends_on_xids)
+{
+	StringInfoData dependencies;
+
+	initStringInfo(&dependencies);
+
+	pq_sendbyte(&dependencies, LOGICAL_REP_MSG_INTERNAL_MESSAGE);
+	pq_sendbyte(&dependencies, PA_MSG_XACT_DEPENDENCY);
+	pq_sendint32(&dependencies, list_length(depends_on_xids));
+
+	foreach_xid(xid, depends_on_xids)
+		pq_sendint32(&dependencies, xid);
+
+	if (pa_send_data(winfo, dependencies.len, dependencies.data))
+		ereport(ERROR,
+				errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+				errmsg("could not send data to the logical replication parallel apply worker"));
+}
+
 /*
  * Handle COMMIT message.
  *
@@ -1771,7 +2490,7 @@ apply_handle_stream_start(StringInfo s)
 
 	/* Try to allocate a worker for the streaming transaction. */
 	if (first_segment)
-		pa_allocate_worker(stream_xid);
+		pa_allocate_worker(stream_xid, true);
 
 	apply_action = get_transaction_apply_action(stream_xid, &winfo);
 
@@ -3980,6 +4699,7 @@ store_flush_position(XLogRecPtr remote_lsn, XLogRecPtr local_lsn)
 	flushpos = palloc_object(FlushPosition);
 	flushpos->local_end = local_lsn;
 	flushpos->remote_end = remote_lsn;
+	flushpos->pa_remote_xid = InvalidTransactionId;
 
 	dlist_push_tail(&lsn_mapping, &flushpos->node);
 	MemoryContextSwitchTo(ApplyMessageContext);
diff --git a/src/include/replication/logicalrelation.h b/src/include/replication/logicalrelation.h
index efe0f9d6031..09890cef8d4 100644
--- a/src/include/replication/logicalrelation.h
+++ b/src/include/replication/logicalrelation.h
@@ -37,6 +37,14 @@ typedef struct LogicalRepRelMapEntry
 	/* Sync state. */
 	char		state;
 	XLogRecPtr	statelsn;
+
+	/*
+	 * The last remote transaction that modified the relation's schema or
+	 * truncated the relation. Used in dependency tracking to ensure subsequent
+	 * transactions modifying the same table wait for this transaction to finish
+	 * applying (see check_dependency_on_rel).
+	 */
+	TransactionId last_depended_xid;
 } LogicalRepRelMapEntry;
 
 extern void logicalrep_relmap_update(LogicalRepRelation *remoterel);
@@ -50,5 +58,6 @@ extern void logicalrep_rel_close(LogicalRepRelMapEntry *rel,
 								 LOCKMODE lockmode);
 extern bool IsIndexUsableForReplicaIdentityFull(Relation idxrel, AttrMap *attrmap);
 extern Oid	GetRelationIdentityOrPK(Relation rel);
+extern LogicalRepRelMapEntry *logicalrep_get_relentry(LogicalRepRelId remoteid);
 
 #endif							/* LOGICALRELATION_H */
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index 43dcae43a62..3c88d9e1027 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -239,6 +239,8 @@ typedef struct ParallelApplyWorkerInfo
 	 */
 	bool		in_use;
 
+	bool		stream_txn;
+
 	ParallelApplyWorkerShared *shared;
 } ParallelApplyWorkerInfo;
 
@@ -350,8 +352,9 @@ extern void apply_error_callback(void *arg);
 extern void set_apply_error_context_origin(char *originname);
 
 /* Parallel apply worker setup and interactions */
-extern void pa_allocate_worker(TransactionId xid);
+extern void pa_allocate_worker(TransactionId xid, bool stream_txn);
 extern ParallelApplyWorkerInfo *pa_find_worker(TransactionId xid);
+extern XLogRecPtr pa_get_last_commit_end(TransactionId xid, bool *skipped_write);
 extern void pa_detach_all_error_mq(void);
 
 extern void apply_handle_internal_message(StringInfo s);
@@ -382,6 +385,7 @@ extern void pa_decr_and_wait_stream_block(void);
 
 extern void pa_xact_finish(ParallelApplyWorkerInfo *winfo,
 						   XLogRecPtr remote_lsn);
+extern bool pa_transaction_committed(TransactionId xid);
 
 extern void pa_wait_for_depended_transaction(TransactionId xid);
 
diff --git a/src/test/subscription/meson.build b/src/test/subscription/meson.build
index e71e95c6297..fdc04d91f41 100644
--- a/src/test/subscription/meson.build
+++ b/src/test/subscription/meson.build
@@ -48,6 +48,7 @@ tests += {
       't/036_sequences.pl',
       't/037_except.pl',
       't/038_walsnd_shutdown_timeout.pl',
+      't/039_row_filter_toast_ri.pl',
       't/100_bugs.pl',
     ],
   },
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index c00c34652fe..07d246bd957 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2669,6 +2669,8 @@ ReplOriginXactState
 ReplaceVarsFromTargetList_context
 ReplaceVarsNoMatchOption
 ReplaceWrapOption
+ReplicaIdentityEntry
+ReplicaIdentityKey
 ReplicaIdentityStmt
 ReplicationKind
 ReplicationSlot
@@ -2680,6 +2682,7 @@ ReplicationSlotPersistentData
 ReplicationState
 ReplicationStateCtl
 ReplicationStateOnDisk
+replica_identity_hash
 ResTarget
 ReservoirState
 ReservoirStateData
-- 
2.43.0



  [application/octet-stream] v20-0002-Introduce-internal-messages-to-track-dependencie.patch (12.7K, ../../TY4PR01MB1771875A7A64B139EF8C8D05C94E62@TY4PR01MB17718.jpnprd01.prod.outlook.com/10-v20-0002-Introduce-internal-messages-to-track-dependencie.patch)
  download | inline diff:
From ef9a8c7ce1160189f9c47535ab8de6edfeadc812 Mon Sep 17 00:00:00 2001
From: Zhijie Hou <[email protected]>
Date: Wed, 29 Apr 2026 16:41:44 +0800
Subject: [PATCH v20 2/4] Introduce internal messages to track dependencies

This patch introduces a new set of internal worker message types (see
PAWorkerMsgType). These types of messages are generated by a leader worker
and sent to parallel apply workers based on the needs. For now, two types of
messages exist: PA_MSG_XACT_DEPENDENCY and PA_MSG_RELMAP.

PA_MSG_XACT_DEPENDENCY ensures that dependent transactions are
committed in the correct order. It has a list of transaction IDs that parallel
workers must wait for. This type of message is generated when the leader
detects a dependency between the current and other transactions, or just before
the COMMIT message. The latter one is used to preserve the commit ordering
between the publisher and the subscriber.

PA_MSG_RELMAP is used to synchronize the remote relation
information between the leader and parallel workers. It has a list of relations
that the leader already knows, and parallel workers also update the relmap in
response to the message. This type of message is generated when the leader
allocates a new parallel worker to the transaction, or when the publisher sends
additional RELATION messages. This synchronization is necessary for parallel
apply workers to map local replication target relations to their remote
counterparts during change application. Since the walsender does not send remote
relation information with every transaction, the parallel apply worker may not
have up-to-date relation info unless synchronized by the leader.

In the logical replication protocol, the above messages are encapsulated within
the LOGICAL_REP_MSG_INTERNAL_MESSAGE type. Later patches will use a uniform
format (LOGICAL_REP_MSG_INTERNAL_MESSAGE + PAWorkerMsgType + internal data) for
sending to parallel apply workers or serializing to disk.

Author: Zhijie Hou <[email protected]>
Author: Hayato Kuroda <[email protected]>
---
 .../replication/logical/applyparallelworker.c | 187 ++++++++++++++++--
 src/backend/replication/logical/proto.c       |   2 +
 src/backend/replication/logical/worker.c      |   4 +
 src/include/replication/logicalproto.h        |   8 +
 src/include/replication/worker_internal.h     |  17 ++
 src/tools/pgindent/typedefs.list              |   1 +
 6 files changed, 202 insertions(+), 17 deletions(-)

diff --git a/src/backend/replication/logical/applyparallelworker.c b/src/backend/replication/logical/applyparallelworker.c
index 5eb0679e850..efde9181a34 100644
--- a/src/backend/replication/logical/applyparallelworker.c
+++ b/src/backend/replication/logical/applyparallelworker.c
@@ -772,6 +772,73 @@ ProcessParallelApplyInterrupts(void)
 	}
 }
 
+/*
+ * Handle internal dependency information.
+ *
+ * Wait for all transactions listed in the message to commit.
+ */
+static void
+apply_handle_internal_dependency(StringInfo s)
+{
+	int			nxids = pq_getmsgint(s, 4);
+
+	for (int i = 0; i < nxids; i++)
+	{
+		TransactionId xid = pq_getmsgint(s, 4);
+
+		pa_wait_for_depended_transaction(xid);
+	}
+}
+
+/*
+ * Handle internal relation information.
+ *
+ * Update all relation details in the relation map cache.
+ */
+static void
+apply_handle_internal_relation(StringInfo s)
+{
+	int			nrels = pq_getmsgint(s, 4);
+
+	for (int i = 0; i < nrels; i++)
+	{
+		LogicalRepRelation *rel = logicalrep_read_rel(s);
+
+		logicalrep_relmap_update(rel);
+
+		/* Also reset all entries in the partition map that refer to remoterel. */
+		logicalrep_partmap_reset_relmap(rel);
+
+		elog(DEBUG1, "parallel apply worker init relmap for %s",
+			 rel->relname);
+	}
+}
+
+/*
+ * Handle an internal message generated by the leader apply worker.
+ */
+void
+apply_handle_internal_message(StringInfo s)
+{
+	PAWorkerMsgType action = pq_getmsgbyte(s);
+
+	Assert(am_parallel_apply_worker());
+
+	switch (action)
+	{
+		case PA_MSG_XACT_DEPENDENCY:
+			apply_handle_internal_dependency(s);
+			break;
+		case PA_MSG_RELMAP:
+			apply_handle_internal_relation(s);
+			break;
+		default:
+			ereport(ERROR,
+					(errcode(ERRCODE_PROTOCOL_VIOLATION),
+					 errmsg("invalid worker internal message type \"??? (%d)\"", action)));
+	}
+}
+
 /* Parallel apply worker main loop. */
 static void
 LogicalParallelApplyLoop(shm_mq_handle *mqh)
@@ -780,6 +847,14 @@ LogicalParallelApplyLoop(shm_mq_handle *mqh)
 	ErrorContextCallback errcallback;
 	MemoryContext oldcxt = CurrentMemoryContext;
 
+	/*
+	 * Ensure LOGICAL_REP_MSG_INTERNAL_MESSAGE does not conflict with
+	 * PqReplMsg_WALData ('d'), as parallel apply workers may receive both types
+	 * of messages.
+	 */
+	StaticAssertDecl(LOGICAL_REP_MSG_INTERNAL_MESSAGE != PqReplMsg_WALData,
+					 "LOGICAL_REP_MSG_INTERNAL_MESSAGE conflicts with PqReplMsg_WALData");
+
 	/*
 	 * Init the ApplyMessageContext which we clean up after each replication
 	 * protocol message.
@@ -818,26 +893,46 @@ LogicalParallelApplyLoop(shm_mq_handle *mqh)
 
 			initReadOnlyStringInfo(&s, data, len);
 
-			/*
-			 * The first byte of messages sent from leader apply worker to
-			 * parallel apply workers can only be PqReplMsg_WALData.
-			 */
 			c = pq_getmsgbyte(&s);
-			if (c != PqReplMsg_WALData)
-				elog(ERROR, "unexpected message \"%c\"", c);
 
-			/*
-			 * Ignore statistics fields that have been updated by the leader
-			 * apply worker.
-			 *
-			 * XXX We can avoid sending the statistics fields from the leader
-			 * apply worker but for that, it needs to rebuild the entire
-			 * message by removing these fields which could be more work than
-			 * simply ignoring these fields in the parallel apply worker.
-			 */
-			s.cursor += SIZE_STATS_MESSAGE;
+			if (c == PqReplMsg_WALData)
+			{
+				/*
+				 * Ignore statistics fields that have been updated by the
+				 * leader apply worker.
+				 *
+				 * XXX We can avoid sending the statistics fields from the
+				 * leader apply worker but for that, it needs to rebuild the
+				 * entire message by removing these fields which could be more
+				 * work than simply ignoring these fields in the parallel apply
+				 * worker.
+				 */
+				s.cursor += SIZE_STATS_MESSAGE;
+
+				apply_dispatch(&s);
+			}
+			else if (c == LOGICAL_REP_MSG_INTERNAL_MESSAGE)
+			{
+				/*
+				 * Rewind the cursor so that apply_dispatch can re-read the
+				 * first byte (LOGICAL_REP_MSG_INTERNAL_MESSAGE) to correctly
+				 * handle the internal message. Alternatively, we could call
+				 * apply_handle_internal_message directly, but using
+				 * apply_dispatch uniformly across all message types is cleaner
+				 * and more consistent.
+				 */
+				s.cursor--;
 
-			apply_dispatch(&s);
+				apply_dispatch(&s);
+			}
+			else
+			{
+				/*
+				 * The first byte of messages sent from leader apply worker to
+				 * parallel apply workers can only be 'w' or 'i'.
+				 */
+				elog(ERROR, "unexpected message \"%c\"", c);
+			}
 		}
 		else if (shmq_res == SHM_MQ_WOULD_BLOCK)
 		{
@@ -1744,3 +1839,61 @@ pa_attach_parallelized_txn_hash(dsa_handle *pa_dsa_handle,
 
 	MemoryContextSwitchTo(oldctx);
 }
+
+/*
+ * Wait for the given remote transaction to finish applying by a parallel apply
+ * worker.
+ *
+ * Both leader and parallel apply workers can call this function to wait for a
+ * parallelized transaction to finish.
+ */
+void
+pa_wait_for_depended_transaction(TransactionId xid)
+{
+	/*
+	 * Quick exit if parallelized_txns has not been initialized yet. This can
+	 * happen when the leader worker calls this function before any parallel
+	 * apply workers have been launched.
+	 */
+	if (!parallelized_txns)
+		return;
+
+	elog(DEBUG1, "wait for depended xid %u", xid);
+
+	for (;;)
+	{
+		ParallelizedTxnEntry *txn_entry;
+
+		txn_entry = dshash_find(parallelized_txns, &xid, false);
+
+		/* The entry is removed only if the transaction is committed */
+		if (txn_entry == NULL)
+			break;
+
+		dshash_release_lock(parallelized_txns, txn_entry);
+
+		/*
+		 * Wait for the parallel apply worker processing the given remote
+		 * transaction to finish applying and release its lock.
+		 */
+		pa_lock_transaction(xid, AccessShareLock);
+		pa_unlock_transaction(xid, AccessShareLock);
+
+		CHECK_FOR_INTERRUPTS();
+
+		/*
+		 * Acquiring the lock successfully does not guarantee we can proceed.
+		 * The worker may have errored out and released the lock while leaving
+		 * its shared hash entry intact, or it may not have acquired the lock
+		 * yet because it hasn't processed the BEGIN message. In either case, we
+		 * must continue waiting in the loop until the parallel apply worker
+		 * finishes applying the transaction, or until the leader notifies us of
+		 * a failure and restarts all workers.
+		 *
+		 * The above race window is small and infrequent, so no WaitLatch is
+		 * added.
+		 */
+	}
+
+	elog(DEBUG1, "finish waiting for depended xid %u", xid);
+}
diff --git a/src/backend/replication/logical/proto.c b/src/backend/replication/logical/proto.c
index 86ad97cd937..a0cac05cdcd 100644
--- a/src/backend/replication/logical/proto.c
+++ b/src/backend/replication/logical/proto.c
@@ -1253,6 +1253,8 @@ logicalrep_message_type(LogicalRepMsgType action)
 			return "STREAM ABORT";
 		case LOGICAL_REP_MSG_STREAM_PREPARE:
 			return "STREAM PREPARE";
+		case LOGICAL_REP_MSG_INTERNAL_MESSAGE:
+			return "INTERNAL MESSAGE";
 	}
 
 	/*
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index a3f2406ed83..6599d563306 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -3890,6 +3890,10 @@ apply_dispatch(StringInfo s)
 			apply_handle_stream_prepare(s);
 			break;
 
+		case LOGICAL_REP_MSG_INTERNAL_MESSAGE:
+			apply_handle_internal_message(s);
+			break;
+
 		default:
 			ereport(ERROR,
 					(errcode(ERRCODE_PROTOCOL_VIOLATION),
diff --git a/src/include/replication/logicalproto.h b/src/include/replication/logicalproto.h
index 058a955e20c..cb0a8f440c0 100644
--- a/src/include/replication/logicalproto.h
+++ b/src/include/replication/logicalproto.h
@@ -53,6 +53,13 @@
  * in logical replication protocol, which uses a single byte to identify a
  * message type. Hence the values should be single-byte wide and preferably
  * human-readable characters.
+ *
+ * LOGICAL_REP_MSG_INTERNAL_MESSAGE ('i') is reserved for internal messages (see
+ * PAWorkerMsgType for sub-message types) sent from the leader apply
+ * worker to parallel apply workers. Centralizing this definition here allows
+ * all message types to be handled together and avoids the maintenance burden of
+ * ensuring sub-message types do not conflict with regular LogicalRepMsgType
+ * values.
  */
 typedef enum LogicalRepMsgType
 {
@@ -75,6 +82,7 @@ typedef enum LogicalRepMsgType
 	LOGICAL_REP_MSG_STREAM_COMMIT = 'c',
 	LOGICAL_REP_MSG_STREAM_ABORT = 'A',
 	LOGICAL_REP_MSG_STREAM_PREPARE = 'p',
+	LOGICAL_REP_MSG_INTERNAL_MESSAGE = 'i',
 } LogicalRepMsgType;
 
 /*
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index e60e0c3d6d7..43dcae43a62 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -242,6 +242,19 @@ typedef struct ParallelApplyWorkerInfo
 	ParallelApplyWorkerShared *shared;
 } ParallelApplyWorkerInfo;
 
+/*
+ * Parallel apply worker internal message types.
+ *
+ * These types of messages are generated by the leader apply worker and sent to
+ * the parallel apply worker, encapsulated within the
+ * LOGICAL_REP_MSG_INTERNAL_MESSAGE type.
+ */
+typedef enum PAWorkerMsgType
+{
+	PA_MSG_XACT_DEPENDENCY = 'd',
+	PA_MSG_RELMAP = 'r',
+} PAWorkerMsgType;
+
 /* Main memory context for apply worker. Permanent during worker lifetime. */
 extern PGDLLIMPORT MemoryContext ApplyContext;
 
@@ -341,6 +354,8 @@ extern void pa_allocate_worker(TransactionId xid);
 extern ParallelApplyWorkerInfo *pa_find_worker(TransactionId xid);
 extern void pa_detach_all_error_mq(void);
 
+extern void apply_handle_internal_message(StringInfo s);
+
 extern bool pa_send_data(ParallelApplyWorkerInfo *winfo, Size nbytes,
 						 const void *data);
 extern void pa_switch_to_partial_serialize(ParallelApplyWorkerInfo *winfo,
@@ -368,6 +383,8 @@ extern void pa_decr_and_wait_stream_block(void);
 extern void pa_xact_finish(ParallelApplyWorkerInfo *winfo,
 						   XLogRecPtr remote_lsn);
 
+extern void pa_wait_for_depended_transaction(TransactionId xid);
+
 #define isParallelApplyWorker(worker) ((worker)->in_use && \
 									   (worker)->type == WORKERTYPE_PARALLEL_APPLY)
 #define isTableSyncWorker(worker) ((worker)->in_use && \
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index fabda17d739..c00c34652fe 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -1926,6 +1926,7 @@ OverridingKind
 PACE_HEADER
 PACL
 PATH
+PAWorkerMsgType
 PCtxtHandle
 PERL_CONTEXT
 PERL_SI
-- 
2.43.0



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

* RE: Parallel Apply
  2026-06-04 06:58 RE: Parallel Apply Zhijie Hou (Fujitsu) <[email protected]>
  2026-06-09 04:14 ` Re: Parallel Apply Peter Smith <[email protected]>
  2026-06-15 11:20   ` RE: Parallel Apply Zhijie Hou (Fujitsu) <[email protected]>
@ 2026-07-09 08:08     ` Zhijie Hou (Fujitsu) <[email protected]>
  0 siblings, 0 replies; 63+ messages in thread

From: Zhijie Hou (Fujitsu) @ 2026-07-09 08:08 UTC (permalink / raw)
  To: Zhijie Hou (Fujitsu) <[email protected]>; +Cc: shveta malik <[email protected]>; Hayato Kuroda (Fujitsu) <[email protected]>; PostgreSQL Hackers <[email protected]>; Tomas Vondra <[email protected]>; Dilip Kumar <[email protected]>; Andrei Lepikhov <[email protected]>; wenhui qiu <[email protected]>; Amit Kapila <[email protected]>; Peter Smith <[email protected]>

On Monday, June 15, 2026 7:20 PM Zhijie Hou (Fujitsu) <[email protected]> wrote:
> 
> Attaching the new version patch set.
> 
> Apart from addressing above comments, I also did the following changes:
> 
> 0003:
> 
> - Fix detection of dependencies on toasted columns by properly copying
> column
>   status.
> - Ensure leader correctly handles table-wide dependencies when applying a
>   transaction directly.
> - Prevent infinite loop by skipping self-dependencies when building the
>   dependency list.
> - Expand test coverage for dependency tracking.
> - Improve code readability through refactoring.
> - Enhance comments for better documentation.
> 
> 0008:
> 0009:
> 
> - Complete most TODOs and finalize the design to cover more cases. Add
> extensive
>   comments to explain the design.
> 
> - Add more tests to cover the new code paths.

I've further reviewed and improved the patch recently; here is the updated V21 patch
set with the following changes:

0003: Fix missing dependency check for replica identity FULL.
0004: Add more test coverage for replica identity FULL.
0007: Improve parallel apply safety check to handle concurrent table schema
      changes.
0009: Fix missing dependency check when both sides of a foreign key are
      partitioned tables, and add more test coverage for partitioned tables.

Best Regards,
Hou zj



Attachments:

  [application/octet-stream] v21-0001-Introduce-a-shared-hash-table-to-store-paralleli.patch (9.1K, ../../TY4PR01MB177180394D8E9C6D263E117E494FE2@TY4PR01MB17718.jpnprd01.prod.outlook.com/2-v21-0001-Introduce-a-shared-hash-table-to-store-paralleli.patch)
  download | inline diff:
From 563e590c157c9db561a9beaf2a4a4a93c9e4576a Mon Sep 17 00:00:00 2001
From: Zhijie Hou <[email protected]>
Date: Wed, 27 May 2026 11:31:35 +0800
Subject: [PATCH v21 1/9] Introduce a shared hash table to store parallelized
 transactions

This commit introduces a shared hash table for tracking parallelized
transactions. The hash table uses transaction IDs as keys; no values are stored.

The hash entries are inserted with a remote XID when the leader bypasses remote
transactions to parallel apply workers. Entries are deleted when parallel
workers are committed to corresponding transactions.

Any worker can use this hash table to check whether a transaction is still
in progress:
- If an entry exists, the transaction is still in progress.
- If no entry exists, the transaction has committed.

Later patches will use this hash table to support dependency waiting and
commit order preservation. When transaction A depends on transaction B
(or must commit after B), the worker will wait for transaction B's entry
to be removed before committing transaction A.

Author: Zhijie Hou <[email protected]>
Author: Hayato Kuroda <[email protected]>
---
 .../replication/logical/applyparallelworker.c | 90 ++++++++++++++++++-
 .../utils/activity/wait_event_names.txt       |  1 +
 src/include/replication/worker_internal.h     |  9 ++
 src/include/storage/lwlocklist.h              |  1 +
 src/tools/pgindent/typedefs.list              |  1 +
 5 files changed, 101 insertions(+), 1 deletion(-)

diff --git a/src/backend/replication/logical/applyparallelworker.c b/src/backend/replication/logical/applyparallelworker.c
index 012d55e9d3d..5eb0679e850 100644
--- a/src/backend/replication/logical/applyparallelworker.c
+++ b/src/backend/replication/logical/applyparallelworker.c
@@ -223,10 +223,36 @@ typedef struct ParallelApplyWorkerEntry
 
 /*
  * A hash table used to cache the state of streaming transactions being applied
- * by the parallel apply workers.
+ * by the parallel apply workers. Entries are of type ParallelApplyWorkerEntry.
  */
 static HTAB *ParallelApplyTxnHash = NULL;
 
+/* An entry in the parallelized_txns shared hash table */
+typedef struct ParallelizedTxnEntry
+{
+	TransactionId xid;			/* Hash key, remote transaction ID */
+} ParallelizedTxnEntry;
+
+/*
+ * A hash table used to track the parallelized remote transactions that could be
+ * depended on by other transactions. Entries are of type ParallelizedTxnEntry.
+ *
+ * dshash is used to enable dynamic shared memory allocation based on the number
+ * of transactions being applied in parallel.
+ */
+static dsa_area *parallel_apply_dsa_area = NULL;
+static dshash_table *parallelized_txns = NULL;
+
+/* Parameters for the parallelized_txns shared hash table */
+static const dshash_parameters dsh_params = {
+	sizeof(TransactionId),
+	sizeof(ParallelizedTxnEntry),
+	dshash_memcmp,
+	dshash_memhash,
+	dshash_memcpy,
+	LWTRANCHE_PARALLEL_APPLY_DSA
+};
+
 /*
  * A list (pool) of active parallel apply workers. The information for
  * the new worker is added to the list after successfully launching it. The
@@ -260,6 +286,8 @@ static List *subxactlist = NIL;
 static void pa_free_worker_info(ParallelApplyWorkerInfo *winfo);
 static ParallelTransState pa_get_xact_state(ParallelApplyWorkerShared *wshared);
 static PartialFileSetState pa_get_fileset_state(void);
+static void pa_attach_parallelized_txn_hash(dsa_handle *pa_dsa_handle,
+											dshash_table_handle *pa_dshash_handle);
 
 /*
  * Returns true if it is OK to start a parallel apply worker, false otherwise.
@@ -337,6 +365,15 @@ pa_setup_dsm(ParallelApplyWorkerInfo *winfo)
 	shm_mq	   *mq;
 	Size		queue_size = DSM_QUEUE_SIZE;
 	Size		error_queue_size = DSM_ERROR_QUEUE_SIZE;
+	dsa_handle	parallel_apply_dsa_handle;
+	dshash_table_handle parallelized_txns_handle;
+
+	pa_attach_parallelized_txn_hash(&parallel_apply_dsa_handle,
+									&parallelized_txns_handle);
+
+	if (parallel_apply_dsa_handle == DSA_HANDLE_INVALID ||
+		parallelized_txns_handle == DSHASH_HANDLE_INVALID)
+		return false;
 
 	/*
 	 * Estimate how much shared memory we need.
@@ -372,6 +409,8 @@ pa_setup_dsm(ParallelApplyWorkerInfo *winfo)
 	pg_atomic_init_u32(&(shared->pending_stream_count), 0);
 	shared->last_commit_end = InvalidXLogRecPtr;
 	shared->fileset_state = FS_EMPTY;
+	shared->parallel_apply_dsa_handle = parallel_apply_dsa_handle;
+	shared->parallelized_txns_handle = parallelized_txns_handle;
 
 	shm_toc_insert(toc, PARALLEL_APPLY_KEY_SHARED, shared);
 
@@ -876,6 +915,8 @@ ParallelApplyWorkerMain(Datum main_arg)
 	shm_mq	   *mq;
 	shm_mq_handle *mqh;
 	shm_mq_handle *error_mqh;
+	dsa_handle	pa_dsa_handle;
+	dshash_table_handle pa_dshash_handle;
 	ReplOriginId originid;
 	int			worker_slot = DatumGetInt32(main_arg);
 	char		originname[NAMEDATALEN];
@@ -962,6 +1003,8 @@ ParallelApplyWorkerMain(Datum main_arg)
 
 	InitializingApplyWorker = false;
 
+	pa_attach_parallelized_txn_hash(&pa_dsa_handle, &pa_dshash_handle);
+
 	/* Setup replication origin tracking. */
 	StartTransactionCommand();
 	ReplicationOriginNameForLogicalRep(MySubscription->oid, InvalidOid,
@@ -1656,3 +1699,48 @@ pa_xact_finish(ParallelApplyWorkerInfo *winfo, XLogRecPtr remote_lsn)
 
 	pa_free_worker(winfo);
 }
+
+/*
+ * Attach to the shared hash table for parallelized transactions.
+ */
+static void
+pa_attach_parallelized_txn_hash(dsa_handle *pa_dsa_handle,
+								dshash_table_handle *pa_dshash_handle)
+{
+	MemoryContext oldctx;
+
+	if (parallelized_txns)
+	{
+		Assert(parallel_apply_dsa_area);
+		*pa_dsa_handle = dsa_get_handle(parallel_apply_dsa_area);
+		*pa_dshash_handle = dshash_get_hash_table_handle(parallelized_txns);
+		return;
+	}
+
+	/* Be sure any local memory allocated by DSA routines is persistent. */
+	oldctx = MemoryContextSwitchTo(ApplyContext);
+
+	if (am_leader_apply_worker())
+	{
+		/* Initialize dynamic shared hash table for parallelized transactions */
+		parallel_apply_dsa_area = dsa_create(LWTRANCHE_PARALLEL_APPLY_DSA);
+		dsa_pin(parallel_apply_dsa_area);
+		dsa_pin_mapping(parallel_apply_dsa_area);
+		parallelized_txns = dshash_create(parallel_apply_dsa_area, &dsh_params, NULL);
+
+		/* Store handles in shared memory for other backends to use. */
+		*pa_dsa_handle = dsa_get_handle(parallel_apply_dsa_area);
+		*pa_dshash_handle = dshash_get_hash_table_handle(parallelized_txns);
+	}
+	else if (am_parallel_apply_worker())
+	{
+		/* Attach to existing dynamic shared hash table. */
+		parallel_apply_dsa_area = dsa_attach(MyParallelShared->parallel_apply_dsa_handle);
+		dsa_pin_mapping(parallel_apply_dsa_area);
+		parallelized_txns = dshash_attach(parallel_apply_dsa_area, &dsh_params,
+										  MyParallelShared->parallelized_txns_handle,
+										  NULL);
+	}
+
+	MemoryContextSwitchTo(oldctx);
+}
diff --git a/src/backend/utils/activity/wait_event_names.txt b/src/backend/utils/activity/wait_event_names.txt
index 560659f9568..1339c33262f 100644
--- a/src/backend/utils/activity/wait_event_names.txt
+++ b/src/backend/utils/activity/wait_event_names.txt
@@ -417,6 +417,7 @@ XactSLRU	"Waiting to access the transaction status SLRU cache."
 ParallelVacuumDSA	"Waiting for parallel vacuum dynamic shared memory allocation."
 AioUringCompletion	"Waiting for another process to complete IO via io_uring."
 ShmemIndex	"Waiting to find or allocate space in shared memory."
+ParallelApplyDSA	"Waiting for parallel apply dynamic shared memory allocation."
 
 # No "ABI_compatibility" region here as WaitEventLWLock has its own C code.
 
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index 745b7d9e969..e60e0c3d6d7 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -15,6 +15,7 @@
 #include "access/xlogdefs.h"
 #include "catalog/pg_subscription.h"
 #include "datatype/timestamp.h"
+#include "lib/dshash.h"
 #include "miscadmin.h"
 #include "replication/logicalrelation.h"
 #include "replication/walreceiver.h"
@@ -196,6 +197,14 @@ typedef struct ParallelApplyWorkerShared
 	 */
 	PartialFileSetState fileset_state;
 	FileSet		fileset;
+
+	/*
+	 * DSA handle for parallel apply workers, along with the handle for the
+	 * shared hash table allocated under it. The hash table stores parallelized
+	 * transaction information.
+	 */
+	dsa_handle	parallel_apply_dsa_handle;
+	dshash_table_handle parallelized_txns_handle;
 } ParallelApplyWorkerShared;
 
 /*
diff --git a/src/include/storage/lwlocklist.h b/src/include/storage/lwlocklist.h
index d7eb648bd27..c5f2921b703 100644
--- a/src/include/storage/lwlocklist.h
+++ b/src/include/storage/lwlocklist.h
@@ -140,3 +140,4 @@ PG_LWLOCKTRANCHE(XACT_SLRU, XactSLRU)
 PG_LWLOCKTRANCHE(PARALLEL_VACUUM_DSA, ParallelVacuumDSA)
 PG_LWLOCKTRANCHE(AIO_URING_COMPLETION, AioUringCompletion)
 PG_LWLOCKTRANCHE(SHMEM_INDEX, ShmemIndex)
+PG_LWLOCKTRANCHE(PARALLEL_APPLY_DSA, ParallelApplyDSA)
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index ffb413ab612..32524990758 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2164,6 +2164,7 @@ ParallelHashJoinBatch
 ParallelHashJoinBatchAccessor
 ParallelHashJoinState
 ParallelIndexScanDesc
+ParallelizedTxnEntry
 ParallelSlot
 ParallelSlotArray
 ParallelSlotResultHandler
-- 
2.43.0



  [application/octet-stream] v21-0009-Support-dependency-tracking-via-local-foreign-ke.patch (49.7K, ../../TY4PR01MB177180394D8E9C6D263E117E494FE2@TY4PR01MB17718.jpnprd01.prod.outlook.com/3-v21-0009-Support-dependency-tracking-via-local-foreign-ke.patch)
  download | inline diff:
From b959f17179689938195efde244fbecff4c9d32ba Mon Sep 17 00:00:00 2001
From: Zhijie Hou <[email protected]>
Date: Tue, 7 Jul 2026 19:18:05 +0800
Subject: [PATCH v21 9/9] Support dependency tracking via local foreign keys

This patch tracks foreign key dependencies to prevent constraint violations.
Consider a referencing table FK_TABLE (a INT REFERENCES PK_TABLE) and a
referenced table PK_TABLE (a INT PRIMARY KEY):

  TX-1: INSERT row (1) INTO PK_TABLE
  TX-2: INSERT row (1) INTO FK_TABLE

TX-2 must wait for TX-1; otherwise, a foreign key violation occurs.

Similarly, for deletions:

  TX-1: DELETE row (1) FROM FK_TABLE
  TX-2: DELETE row (1) FROM PK_TABLE

TX-2 must wait for TX-1 to avoid violating the foreign key constraint.

Therefore, we record new tuples on the referenced table (PK_TABLE) and check
for dependencies when processing new tuples on the referencing table
(FK_TABLE). Conversely, we record old tuples deleted from the referencing
table and require deletions on the referenced table to depend on them.

Similar to unique key dependencies, for delete-delete cases we only track
foreign key dependencies on columns that are part of the replica identity
keys on the publisher. This may lead to false positives but is acceptable
given the trade-off.
---
 .../replication/logical/applyparallelworker.c |  26 ++
 src/backend/replication/logical/relation.c    | 384 +++++++++++++++++
 src/backend/replication/logical/worker.c      | 395 +++++++++++++++++-
 src/include/replication/logicalrelation.h     |  30 ++
 src/test/subscription/t/050_parallel_apply.pl | 330 +++++++++++++++
 5 files changed, 1150 insertions(+), 15 deletions(-)

diff --git a/src/backend/replication/logical/applyparallelworker.c b/src/backend/replication/logical/applyparallelworker.c
index 1f1a75b5019..733879d1edc 100644
--- a/src/backend/replication/logical/applyparallelworker.c
+++ b/src/backend/replication/logical/applyparallelworker.c
@@ -218,6 +218,32 @@
  * replica identity keys. This could be useful for users who prefer higher
  * parallelism and experience few conflicts.
  *
+ * Additionally, foreign key dependencies are also tracked to prevent constraint
+ * violations. Consider a referencing table FK_TABLE (a INT REFERENCES PK_TABLE)
+ * and a referenced table PK_TABLE (a INT PRIMARY KEY):
+ *
+ *   TX-1: INSERT row (1) INTO PK_TABLE
+ *   TX-2: INSERT row (1) INTO FK_TABLE
+ *
+ * TX-2 must wait for TX-1; otherwise, a foreign key violation occurs.
+ *
+ * Similarly, for deletions:
+ *
+ *   TX-1: DELETE row (1) FROM FK_TABLE
+ *   TX-2: DELETE row (1) FROM PK_TABLE
+ *
+ * TX-2 must wait for TX-1 to avoid violating the foreign key constraint.
+ *
+ * Therefore, we record new tuples on the referenced table (PK_TABLE) and check
+ * for dependencies when processing new tuples on the referencing table
+ * (FK_TABLE). Conversely, we record old tuples deleted from the referencing
+ * table and require deletions on the referenced table to depend on them.
+ *
+ * Similar to unique key dependencies, for delete-delete cases we only track
+ * foreign key dependencies on columns that are part of the replica identity
+ * keys on the publisher. This may lead to false positives but is acceptable
+ * given the trade-off.
+ *
  * Note that the local unique key could change after dependency checking and
  * before applying the change. However, to centralize tracking and keep it
  * simple, we still perform this check only in the leader apply worker. This is
diff --git a/src/backend/replication/logical/relation.c b/src/backend/replication/logical/relation.c
index 1572990ee18..cdafa10beb3 100644
--- a/src/backend/replication/logical/relation.c
+++ b/src/backend/replication/logical/relation.c
@@ -20,7 +20,11 @@
 #include "access/amapi.h"
 #include "access/genam.h"
 #include "access/table.h"
+#include "catalog/heap.h"
 #include "catalog/namespace.h"
+#include "catalog/partition.h"
+#include "catalog/pg_constraint.h"
+#include "catalog/pg_inherits.h"
 #include "catalog/pg_proc.h"
 #include "catalog/pg_subscription_rel.h"
 #include "catalog/pg_trigger.h"
@@ -31,6 +35,7 @@
 #include "replication/logicalrelation.h"
 #include "replication/worker_internal.h"
 #include "rewrite/rewriteHandler.h"
+#include "utils/fmgroids.h"
 #include "utils/inval.h"
 #include "utils/lsyscache.h"
 #include "utils/syscache.h"
@@ -62,6 +67,10 @@ typedef struct LogicalRepPartMapEntry
 
 static Oid	FindLogicalRepLocalIndex(Relation localrel, LogicalRepRelation *remoterel,
 									 AttrMap *attrMap);
+static void logicalrep_map_attnums_by_name(Oid src_reloid, Oid dst_reloid,
+										   int nkeys,
+										   const AttrNumber *src_attnums,
+										   AttrNumber *dst_attnums);
 
 /*
  * Relcache invalidation callback for our relation map cache.
@@ -89,6 +98,7 @@ logicalrep_relmap_invalidate_cb(Datum arg, Oid reloid)
 				entry->localrelvalid = false;
 				entry->parallel_safety_valid = false;
 				entry->local_unique_indexes_valid = false;
+				entry->local_fkeys_valid = false;
 				hash_seq_term(&status);
 				break;
 			}
@@ -106,6 +116,7 @@ logicalrep_relmap_invalidate_cb(Datum arg, Oid reloid)
 			entry->localrelvalid = false;
 			entry->parallel_safety_valid = false;
 			entry->local_unique_indexes_valid = false;
+			entry->local_fkeys_valid = false;
 		}
 	}
 }
@@ -152,6 +163,33 @@ free_local_unique_indexes(LogicalRepRelMapEntry *entry)
 	entry->local_unique_indexes = NIL;
 }
 
+/*
+ * Release local foreign key lists.
+ */
+static void
+free_local_fkeys(LogicalRepRelMapEntry *entry)
+{
+	Assert(am_leader_apply_worker());
+
+	foreach_ptr(LogicalRepSubFKey, fkinfo, entry->local_fkeys)
+	{
+		list_free(fkinfo->fkattnums_new);
+		list_free(fkinfo->fkattnums_old);
+	}
+
+	foreach_ptr(LogicalRepSubRefKey, refinfo, entry->local_refkeys)
+	{
+		list_free(refinfo->refattnums_new);
+		list_free(refinfo->refattnums_old);
+	}
+
+	list_free_deep(entry->local_fkeys);
+	list_free_deep(entry->local_refkeys);
+
+	entry->local_fkeys = NIL;
+	entry->local_refkeys = NIL;
+}
+
 /*
  * Free the entry of a relation map cache.
  */
@@ -182,6 +220,9 @@ logicalrep_relmap_free_entry(LogicalRepRelMapEntry *entry)
 
 	if (entry->local_unique_indexes != NIL)
 		free_local_unique_indexes(entry);
+
+	if (entry->local_fkeys != NIL || entry->local_refkeys != NIL)
+		free_local_fkeys(entry);
 }
 
 /*
@@ -432,6 +473,7 @@ logicalrep_rel_open(LogicalRepRelId remoteid, LOCKMODE lockmode)
 			entry->localrelvalid = false;
 			entry->parallel_safety_valid = false;
 			entry->local_unique_indexes_valid = false;
+			entry->local_fkeys_valid = false;
 		}
 		else if (!entry->localrelvalid)
 		{
@@ -793,6 +835,348 @@ logicalrep_build_dependent_unique_indexes(LogicalRepRelMapEntry *entry)
 	entry->local_unique_indexes_valid = true;
 }
 
+/*
+ * Return all local relation map entries that match the given list of local
+ * relation OIDs.
+ */
+static List *
+logicalrep_get_local_relentries(List *localrelids)
+{
+	HASH_SEQ_STATUS status;
+	LogicalRepRelMapEntry *entry;
+	List	   *entries = NIL;
+
+	if (LogicalRepRelMap == NULL)
+		return NIL;
+
+	hash_seq_init(&status, LogicalRepRelMap);
+	while ((entry = (LogicalRepRelMapEntry *) hash_seq_search(&status)) != NULL)
+	{
+		if (!OidIsValid(entry->localreloid))
+			continue;
+
+		if (list_member_oid(localrelids, entry->localreloid))
+			entries = lappend(entries, entry);
+	}
+
+	return entries;
+}
+
+/*
+ * Map key columns from src_reloid to dst_reloid by attribute name.
+ */
+static void
+logicalrep_map_attnums_by_name(Oid src_reloid, Oid dst_reloid,
+							   int nkeys,
+							   const AttrNumber *src_attnums,
+							   AttrNumber *dst_attnums)
+{
+	if (src_reloid == dst_reloid)
+	{
+		memcpy(dst_attnums, src_attnums, nkeys * sizeof(AttrNumber));
+		return;
+	}
+
+	for (int i = 0; i < nkeys; i++)
+	{
+		char	   *attname;
+		AttrNumber	attnum;
+
+		attname = get_attname(src_reloid, src_attnums[i], false);
+		attnum = get_attnum(dst_reloid, attname);
+		pfree(attname);
+
+		Assert(AttributeNumberIsValid(attnum));
+
+		dst_attnums[i] = attnum;
+	}
+}
+
+/*
+ * Check whether the table has any foreign key triggers of the given type
+ * (trig_type) enabled in replica mode.
+ */
+static bool
+fkey_trigger_enabled_in_replica(Relation rel, Oid conoid, int trig_type)
+{
+	TriggerDesc *trigdesc = rel->trigdesc;
+
+	if (trigdesc == NULL)
+		return false;
+
+	for (int i = 0; i < trigdesc->numtriggers; i++)
+	{
+		Trigger    *trig = &trigdesc->triggers[i];
+
+		/* Keep only PK-side action triggers for this FK constraint */
+		if (trig->tgconstraint != conoid ||
+			RI_FKey_trigger_type(trig->tgfoid) != trig_type)
+			continue;
+
+		/* In replica mode, only REPLICA/ALWAYS triggers can fire. */
+		if (trig->tgenabled == TRIGGER_FIRES_ON_REPLICA ||
+			trig->tgenabled == TRIGGER_FIRES_ALWAYS)
+			return true;
+	}
+
+	return false;
+}
+
+/*
+ * Build lists of foreign key and referenced primary key remote columns. Both
+ * lists are ordered according to the referenced table's remote column order.
+ */
+static void
+build_fk_remote_attnums(LogicalRepRelMapEntry *fkentry,
+						LogicalRepRelMapEntry *refentry,
+						int nkeys,
+						const AttrNumber *fk_conkey,
+						const AttrNumber *ref_confkey,
+						int trig_type,
+						List **fkattnums_new,
+						List **fkattnums_old)
+{
+	int			pkatt = -1;
+
+	Assert(trig_type == RI_TRIGGER_FK || trig_type == RI_TRIGGER_PK);
+
+	/*
+	 * Traverse the referenced table's remote replica identity columns in order,
+	 * and for each, find the corresponding foreign key column that matches it.
+	 */
+	while ((pkatt = bms_next_member(refentry->remoterel.attkeys, pkatt)) >= 0)
+	{
+		for (int i = 0; i < nkeys; i++)
+		{
+			AttrNumber	fk_local_attnum = fk_conkey[i];
+			AttrNumber	ref_local_attnum = ref_confkey[i];
+			int			fk_remote_attnum;
+			int			ref_remote_attnum;
+			int			mapped_attnum;
+
+			fk_remote_attnum = fkentry->attrmap->attnums[AttrNumberGetAttrOffset(fk_local_attnum)];
+			ref_remote_attnum = refentry->attrmap->attnums[AttrNumberGetAttrOffset(ref_local_attnum)];
+
+			/* Skip if not the current traversed column */
+			if (ref_remote_attnum != pkatt)
+				continue;
+
+			/* Skip columns that are unavailable in the remote table */
+			if (ref_remote_attnum < 0 || fk_remote_attnum < 0)
+				continue;
+
+			if (trig_type == RI_TRIGGER_FK)
+				mapped_attnum = fk_remote_attnum + 1;
+			else if (trig_type == RI_TRIGGER_PK)
+				mapped_attnum = ref_remote_attnum + 1;
+
+			*fkattnums_new = lappend_int(*fkattnums_new, mapped_attnum);
+
+			/* Old tuple contains only replica identity columns. */
+			if (bms_is_member(fk_remote_attnum, fkentry->remoterel.attkeys) &&
+				bms_is_member(ref_remote_attnum, refentry->remoterel.attkeys))
+				*fkattnums_old = lappend_int(*fkattnums_old, mapped_attnum);
+
+			break;
+		}
+	}
+}
+
+/*
+ * Collect foreign keys for dependency tracking.
+ *
+ * This function collects both foreign keys in the referencing table and the
+ * primary key in the referenced table, as both are needed for dependency
+ * tracking (see applyparallelworker.c for details).
+ *
+ * For referencing-side foreign keys, we cannot use the referencing column
+ * numbers directly, because their order may differ from the primary key column
+ * order in the referenced table. To ensure consistency, we store a list of
+ * column numbers in the order of the referenced table's remote columns. The
+ * dependency tracking function will traverse this list to build the hash key.
+ * For referenced-side primary keys, we preserve the original column order.
+ *
+ * For one foreign key constraint on the table, We collect two set of column
+ * numbers for both the referencing and referenced keys:
+ *
+ * - One for INSERT-INSERT dependency tracking, using the new tuple. This
+ *   includes all remote columns referenced by the foreign key, as the new tuple
+ *   contains all remote values.
+ *
+ * - One for DELETE-DELETE dependency tracking, using the old tuple. This
+ *   includes only columns that are part of the replica identity key, because
+ *   the old tuple of an UPDATE or DELETE contains only replica identity key
+ *   columns. Any other columns would be missing and unavailable for dependency
+ *   tracking.
+ *
+ * If there are multiple foreign keys referencing other tables, or if the
+ * primary key is referenced by multiple foreign keys, we will have multiple
+ * column number sets. One might think that for a referenced primary key, we
+ * only need to store a single column bitmap (since only one primary key
+ * exists). However, the referencing side may not have all those column values
+ * available (columns outside the replica identity). Therefore, we store
+ * multiple primary key column sets, each including only the columns that are
+ * available on both sides. This ensures that the remote values on both the
+ * referenced and referencing tables can compute the same hash value.
+ */
+void
+logicalrep_build_dependent_fkeys(LogicalRepRelMapEntry *entry)
+{
+	int			trig_type;
+	Relation	fkeyRel;
+	SysScanDesc fkeyScan;
+	HeapTuple	tuple;
+	Oid			relid = RelationGetRelid(entry->localrel);
+
+	Assert(OidIsValid(relid));
+
+	if (entry->local_fkeys_valid)
+		return;
+
+	free_local_fkeys(entry);
+
+	fkeyRel = table_open(ConstraintRelationId, AccessShareLock);
+
+	fkeyScan = systable_beginscan(fkeyRel, InvalidOid, false,
+								  NULL, 0, NULL);
+
+	while (HeapTupleIsValid(tuple = systable_getnext(fkeyScan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(tuple);
+		List	   *fkentries;
+		AttrNumber	conkey[INDEX_MAX_KEYS] = {0};
+		AttrNumber	confkey[INDEX_MAX_KEYS] = {0};
+		AttrNumber *key_to_map;
+		int			numfks;
+		MemoryContext oldctx;
+		List	   *all_parts;
+		Oid			otherrelid;
+
+		/* Not a foreign key */
+		if (con->contype != CONSTRAINT_FOREIGN)
+			continue;
+
+		/* Skip if FK enforcement is disabled */
+		if (!con->conenforced)
+			continue;
+
+		/*
+		 * Deferrable foreign keys do not need tracking, as they won't cause
+		 * constraint violations as long as commit order is preserved.
+		 */
+		if (con->condeferrable && con->condeferred)
+			continue;
+
+		if (con->confrelid == relid)
+		{
+			trig_type = RI_TRIGGER_PK;
+			otherrelid = con->conrelid;
+			key_to_map = conkey;
+		}
+		else if (con->conrelid == relid)
+		{
+			trig_type = RI_TRIGGER_FK;
+			otherrelid = con->confrelid;
+			key_to_map = confkey;
+		}
+		else
+			continue;
+
+		/*
+		 * Skip when no replica-mode ON DELETE/ON UPDATE violation trigger can
+		 * fire for this FK on the referenced table.
+		 */
+		if (!fkey_trigger_enabled_in_replica(entry->localrel, con->oid,
+											 trig_type))
+			continue;
+
+		DeconstructFkConstraintRow(tuple, &numfks, conkey, confkey,
+								   NULL, NULL, NULL, NULL, NULL);
+
+		/*
+		 * Collect all partitions of the referenced (or referencing) table if
+		 * it's partitioned, and cache foreign key data between the current
+		 * table and all of its partitions.
+		 *
+		 * This is necessary when both the referencing and referenced sides are
+		 * partitioned tables, as pg_constraint does not record foreign key
+		 * relationships between child partitions. We must search them
+		 * explicitly.
+		 *
+		 * No lock is needed on each partition because we already hold a lock on
+		 * the referencing table, preventing concurrent alterations that would
+		 * affect foreign key dependencies.
+		 */
+		all_parts = find_all_inheritors(otherrelid, NoLock, NULL);
+		fkentries = logicalrep_get_local_relentries(all_parts);
+
+		foreach_ptr(LogicalRepRelMapEntry, fkentry, fkentries)
+		{
+			AttrNumber	mapped_conkey[INDEX_MAX_KEYS] = {0};
+
+			/*
+			 * Skip if the referencing table is not published or has not
+			 * replicated any changes.
+			 */
+			if (!fkentry->attrmap)
+				continue;
+
+			/*
+			 * Map column numbers from the parent to the corresponding child
+			 * columns.
+			 */
+			logicalrep_map_attnums_by_name(otherrelid,
+										   fkentry->localreloid,
+										   numfks,
+										   key_to_map,
+										   mapped_conkey);
+
+			oldctx = MemoryContextSwitchTo(LogicalRepRelMapContext);
+
+			if (trig_type == RI_TRIGGER_PK)
+			{
+				LogicalRepSubRefKey *refinfo;
+
+				refinfo = palloc0_object(LogicalRepSubRefKey);
+				refinfo->conoid = con->oid;
+				refinfo->fk_remoteid = fkentry->remoterel.remoteid;
+
+				build_fk_remote_attnums(fkentry, entry, numfks, mapped_conkey,
+										confkey, trig_type,
+										&refinfo->refattnums_new,
+										&refinfo->refattnums_old);
+
+				entry->local_refkeys = lappend(entry->local_refkeys, refinfo);
+			}
+			else if (trig_type == RI_TRIGGER_FK)
+			{
+				LogicalRepSubFKey *fkinfo;
+
+				fkinfo = palloc0_object(LogicalRepSubFKey);
+				fkinfo->conoid = con->oid;
+				fkinfo->ref_remoteid = fkentry->remoterel.remoteid;
+
+				build_fk_remote_attnums(entry, fkentry, numfks, conkey,
+										mapped_conkey, trig_type,
+										&fkinfo->fkattnums_new,
+										&fkinfo->fkattnums_old);
+
+				entry->local_fkeys = lappend(entry->local_fkeys, fkinfo);
+			}
+
+			MemoryContextSwitchTo(oldctx);
+		}
+
+		list_free(fkentries);
+	}
+
+	systable_endscan(fkeyScan);
+
+	table_close(fkeyRel, AccessShareLock);
+
+	entry->local_fkeys_valid = true;
+}
+
 /*
  * Partition cache: look up partition LogicalRepRelMapEntry's
  *
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 3864c95f930..08b4ddb094a 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -255,6 +255,7 @@
 #include "access/twophase.h"
 #include "access/xact.h"
 #include "catalog/indexing.h"
+#include "catalog/namespace.h"
 #include "catalog/pg_inherits.h"
 #include "catalog/pg_subscription.h"
 #include "catalog/pg_subscription_rel.h"
@@ -265,6 +266,7 @@
 #include "executor/execPartition.h"
 #include "libpq/pqformat.h"
 #include "miscadmin.h"
+#include "nodes/makefuncs.h"
 #include "optimizer/optimizer.h"
 #include "parser/parse_relation.h"
 #include "pgstat.h"
@@ -584,7 +586,10 @@ static ApplySubXactData subxact_data = {0, 0, InvalidTransactionId, NULL};
 typedef enum LogicalRepKeyKind
 {
 	LOGICALREP_KEY_REPLICA_IDENTITY,
-	LOGICALREP_KEY_LOCAL_UNIQUE
+	LOGICALREP_KEY_LOCAL_UNIQUE,
+	LOGICALREP_KEY_FOREIGN_KEY,
+	LOGICALREP_KEY_REFERENCED_KEY,
+	LOGICALREP_ALL_KEYS,
 } LogicalRepKeyKind;
 
 /* Hash table key for replica_identity_table */
@@ -950,6 +955,26 @@ append_xid_dependency(TransactionId xid, List **depends_on_xids)
 	*depends_on_xids = lappend_xid(*depends_on_xids, xid);
 }
 
+static char *
+get_dependency_type_str(LogicalRepKeyKind kind)
+{
+	switch (kind)
+	{
+		case LOGICALREP_KEY_REPLICA_IDENTITY:
+			return "replica identity";
+		case LOGICALREP_KEY_LOCAL_UNIQUE:
+			return "local unique key";
+		case LOGICALREP_KEY_FOREIGN_KEY:
+			return "foreign key";
+		case LOGICALREP_KEY_REFERENCED_KEY:
+			return "referenced key";
+		case LOGICALREP_ALL_KEYS:
+			return "all keys";
+	}
+
+	return "???";
+}
+
 /*
  * Common function for checking dependency by using the key. Used by both
  * check_and_record_ri_dependency and check_and_record_local_key_dependency.
@@ -982,9 +1007,8 @@ check_and_record_key_dependency(ReplicaIdentityKey *key,
 		if (rientry && has_active_key_dependency(rientry, true))
 		{
 			elog(DEBUG1,
-				 key->kind == LOGICALREP_KEY_REPLICA_IDENTITY ?
-				 "found conflicting replica identity change on table %u from %u" :
-				 "found conflicting local unique key change on table %u from %u",
+				 "found conflicting %s change on table %u from %u",
+				 get_dependency_type_str(key->kind),
 				 key->relid, rientry->remote_xid);
 
 			existing_xid = rientry->remote_xid;
@@ -1007,9 +1031,8 @@ check_and_record_key_dependency(ReplicaIdentityKey *key,
 		if (has_active_key_dependency(rientry, false))
 		{
 			elog(DEBUG1,
-				 key->kind == LOGICALREP_KEY_REPLICA_IDENTITY ?
-				 "found conflicting replica identity change on table %u from %u" :
-				 "found conflicting local unique key change on table %u from %u",
+				 "found conflicting %s change on table %u from %u",
+				 get_dependency_type_str(key->kind),
 				 key->relid, rientry->remote_xid);
 
 			existing_xid = rientry->remote_xid;
@@ -1039,6 +1062,23 @@ has_null_key_values(LogicalRepTupleData *data, Bitmapset *indexkeys)
 	return false;
 }
 
+/*
+ * Check if any of the attnums have NULL values.
+ */
+static bool
+fk_tuple_has_null(LogicalRepTupleData *data, List *attnums)
+{
+	foreach_int(attnum, attnums)
+	{
+		int		attidx = AttrNumberGetAttrOffset(attnum);
+
+		if (data->colstatus[attidx] == LOGICALREP_COLUMN_NULL)
+			return true;
+	}
+
+	return false;
+}
+
 /*
  * Build a hash key for replica_identity_table using the given relation and
  * tuple data, restricted to the specified key columns.
@@ -1104,18 +1144,81 @@ build_replica_identity_key(Oid relid, LogicalRepTupleData *original_data,
 	return key;
 }
 
+/*
+ * Similar to build_replica_identity_key(), but builds the hash key from
+ * explicitly ordered columns.
+ */
+static ReplicaIdentityKey *
+build_replica_identity_key_by_attnums(Oid relid,
+									  LogicalRepTupleData *original_data,
+									  List *attnums)
+{
+	LogicalRepTupleData *keydata;
+	ReplicaIdentityKey *key;
+	MemoryContext oldctx;
+	int	i_key = 0;
+	int nattnums = list_length(attnums);
+
+	oldctx = MemoryContextSwitchTo(ApplyContext);
+
+	keydata = palloc0_object(LogicalRepTupleData);
+
+	if (nattnums)
+	{
+		keydata->colvalues = palloc0_array(StringInfoData, nattnums);
+		keydata->colstatus = palloc0_array(char, nattnums);
+	}
+
+	keydata->ncols = nattnums;
+
+	foreach_int(attnum, attnums)
+	{
+		int		attidx = AttrNumberGetAttrOffset(attnum);
+		StringInfo	original_colvalue;
+
+		keydata->colstatus[i_key] = original_data->colstatus[attidx];
+
+		if (keydata->colstatus[i_key] != LOGICALREP_COLUMN_NULL)
+		{
+			Assert(original_data->colstatus[attidx] != LOGICALREP_COLUMN_UNCHANGED ||
+				   original_data->colvalues[attidx].len > 0);
+
+			original_colvalue = &original_data->colvalues[attidx];
+
+			initStringInfoExt(&keydata->colvalues[i_key], original_colvalue->len + 1);
+			appendStringInfoString(&keydata->colvalues[i_key], original_colvalue->data);
+		}
+
+		i_key++;
+	}
+
+	key = palloc0_object(ReplicaIdentityKey);
+	key->relid = relid;
+	key->data = keydata;
+
+	MemoryContextSwitchTo(oldctx);
+
+	return key;
+}
+
 /*
  * Build dependency key information for the given relation entry if not already
  * collected.
  *
- * See logicalrep_build_dependent_unique_indexes() for details.
+ * When missing_ok is true, we don't throw an error if the local relation
+ * doesn't exist.
+ *
+ * See logicalrep_build_dependent_unique_indexes() and
+ * logicalrep_build_dependent_fkeys() for details.
  */
 static void
-build_local_dependent_key_info(LogicalRepRelMapEntry *relentry)
+build_local_dependent_key_info(LogicalRepRelMapEntry *relentry, bool missing_ok)
 {
+	Oid			localrelid = relentry->localreloid;
 	bool		needs_start;
 
-	if (relentry->local_unique_indexes_valid)
+	if (relentry->local_unique_indexes_valid &&
+		relentry->local_fkeys_valid)
 		return;
 
 	/*
@@ -1127,11 +1230,20 @@ build_local_dependent_key_info(LogicalRepRelMapEntry *relentry)
 	if (needs_start)
 		StartTransactionCommand();
 
-	relentry = logicalrep_rel_open(relentry->remoterel.remoteid, AccessShareLock);
+	if (!OidIsValid(localrelid))
+		localrelid = RangeVarGetRelid(makeRangeVar(relentry->remoterel.nspname,
+												   relentry->remoterel.relname, -1),
+									  AccessShareLock, missing_ok);
 
-	logicalrep_build_dependent_unique_indexes(relentry);
+	if (OidIsValid(localrelid))
+	{
+		relentry = logicalrep_rel_open(relentry->remoterel.remoteid, AccessShareLock);
+
+		logicalrep_build_dependent_unique_indexes(relentry);
+		logicalrep_build_dependent_fkeys(relentry);
 
-	logicalrep_rel_close(relentry, AccessShareLock);
+		logicalrep_rel_close(relentry, AccessShareLock);
+	}
 
 	if (needs_start)
 		CommitTransactionCommand();
@@ -1160,7 +1272,7 @@ check_and_record_local_key_dependency(Oid relid,
 
 	Assert(relentry);
 
-	build_local_dependent_key_info(relentry);
+	build_local_dependent_key_info(relentry, false);
 
 	foreach_ptr(LogicalRepSubUniqueIndex, idxinfo, relentry->local_unique_indexes)
 	{
@@ -1215,6 +1327,135 @@ check_and_record_local_key_dependency(Oid relid,
 	}
 }
 
+/*
+ * Check and record dependencies caused by foreign key constraints.
+ *
+ * A new value in the referencing table depends on the referenced table's data.
+ * Conversely, a deletion in the referenced table depends on whether the
+ * referencing table still contains matching rows.
+ *
+ * See applyparallelworker.c for details on why tracking these dependencies is
+ * necessary.
+ */
+static void
+check_and_record_fkey_dependency(Oid relid,
+								 LogicalRepTupleData *original_data,
+								 bool old_tuple,
+								 TransactionId new_depended_xid,
+								 List **depends_on_xids)
+{
+	LogicalRepRelMapEntry *relentry;
+	ReplicaIdentityKey *rikey;
+
+	Assert(depends_on_xids);
+
+	relentry = logicalrep_get_relentry(relid);
+
+	Assert(relentry);
+
+	build_local_dependent_key_info(relentry, false);
+
+	foreach_ptr(LogicalRepSubFKey, fkinfo, relentry->local_fkeys)
+	{
+		List	   *keyattnums = old_tuple ?
+			fkinfo->fkattnums_old : fkinfo->fkattnums_new;
+
+		/* NULL value won't violate foreign key violation */
+		if (fk_tuple_has_null(original_data, keyattnums))
+			continue;
+
+		/*
+		 * Old tuples of foreign keys do not conflict with any preceding
+		 * transaction (see the comments in applyparallelworker.c for details on
+		 * conflicting cases). When we don't need to record a new dependency, we
+		 * can skip processing this index entirely.
+		 */
+		if (old_tuple && !TransactionIdIsValid(new_depended_xid))
+			continue;
+
+		rikey = build_replica_identity_key_by_attnums(fkinfo->ref_remoteid,
+													  original_data,
+													  keyattnums);
+
+		/*
+		 * For old tuples, record a dependency that later transactions deleting
+		 * the same key from the referenced table must wait on. No preceding
+		 * transactions are added to the list.
+		 *
+		 * For new tuples, check for existing dependencies on the same key
+		 * inserted into the referenced table and add any dependent transactions
+		 * to the list.
+		 */
+		if (old_tuple)
+		{
+			rikey->kind = LOGICALREP_KEY_FOREIGN_KEY;
+			(void) check_and_record_key_dependency(rikey, new_depended_xid);
+		}
+		else
+		{
+			TransactionId	dep_xid;
+
+			rikey->kind = LOGICALREP_KEY_REFERENCED_KEY;
+			dep_xid = check_and_record_key_dependency(rikey, InvalidTransactionId);
+
+			if (TransactionIdIsValid(dep_xid) &&
+				!TransactionIdEquals(dep_xid, new_depended_xid))
+				append_xid_dependency(dep_xid, depends_on_xids);
+		}
+	}
+
+	/* Return if no referenced key exists */
+	if (relentry->local_refkeys == NIL)
+		return;
+
+	foreach_ptr(LogicalRepSubRefKey, refinfo, relentry->local_refkeys)
+	{
+		List	   *keyattnums = old_tuple ?
+			refinfo->refattnums_old : refinfo->refattnums_new;
+
+		/* Shouldn't be real NULL value in referenced key (primary key). */
+		Assert(old_tuple || !fk_tuple_has_null(original_data, keyattnums));
+
+		/*
+		 * New tuples of referenced keys do not conflict with any preceding
+		 * transaction (see the comments in applyparallelworker.c for details on
+		 * conflicting cases). When we don't need to record a new dependency, we
+		 * can skip processing this key entirely.
+		 */
+		if (!old_tuple && !TransactionIdIsValid(new_depended_xid))
+			continue;
+
+		rikey = build_replica_identity_key_by_attnums(relid, original_data,
+													  keyattnums);
+
+		/*
+		 * For old tuples, check for existing dependencies on the same key
+		 * deleted from the referencing table and add any dependent transactions
+		 * to the list.
+		 *
+		 * For new tuples, record a dependency that later transactions inserting
+		 * the same key into the referencing table must wait on. No preceding
+		 * transactions are added to the list.
+		 */
+		if (old_tuple)
+		{
+			TransactionId	dep_xid;
+
+			rikey->kind = LOGICALREP_KEY_FOREIGN_KEY;
+			dep_xid = check_and_record_key_dependency(rikey, InvalidTransactionId);
+
+			if (TransactionIdIsValid(dep_xid) &&
+				!TransactionIdEquals(dep_xid, new_depended_xid))
+				append_xid_dependency(dep_xid, depends_on_xids);
+		}
+		else
+		{
+			rikey->kind = LOGICALREP_KEY_REFERENCED_KEY;
+			(void) check_and_record_key_dependency(rikey, new_depended_xid);
+		}
+	}
+}
+
 /*
  * Check for dependencies on preceding transactions that modify the same key.
  * Returns the dependent transactions in 'depends_on_xids'.
@@ -1290,6 +1531,7 @@ check_and_record_ri_dependency(Oid relid, LogicalRepTupleData *original_data,
 static void
 find_all_dependencies_on_rel(LogicalRepRelId relid,
 							 TransactionId new_depended_xid,
+							 LogicalRepKeyKind kind,
 							 List **depends_on_xids)
 {
 	replica_identity_iterator i;
@@ -1305,6 +1547,10 @@ find_all_dependencies_on_rel(LogicalRepRelId relid,
 		if (rientry->keydata->relid != relid)
 			continue;
 
+		if (kind != LOGICALREP_ALL_KEYS &&
+			rientry->keydata->kind != kind)
+			continue;
+
 		/* Skip entries that do not have a valid and uncommitted transaction */
 		if (!has_active_key_dependency(rientry, true))
 			continue;
@@ -1337,7 +1583,8 @@ check_and_record_rel_dependency(LogicalRepRelId relid,
 
 	Assert(depends_on_xids);
 
-	find_all_dependencies_on_rel(relid, new_depended_xid, depends_on_xids);
+	find_all_dependencies_on_rel(relid, new_depended_xid, LOGICALREP_ALL_KEYS,
+								 depends_on_xids);
 
 	/* Search for existing entry */
 	relentry = logicalrep_get_relentry(relid);
@@ -1363,6 +1610,100 @@ check_and_record_rel_dependency(LogicalRepRelId relid,
 		relentry->last_depended_xid = new_depended_xid;
 }
 
+/*
+ * Check for preceding transactions that modified tables referencing or
+ * referenced by the given table. Any dependent transaction IDs are added to
+ * 'depends_on_xids'.
+ *
+ * This function is called only for TRUNCATE and remote schema change that
+ * affect the entire table.
+ *
+ * For remote schema changes, foreign key information of all referencing and
+ * referenced side tables is also invalidated.
+ */
+static void
+find_frel_dependency(LogicalRepRelId relid, TransactionId new_depended_xid,
+					 LogicalRepMsgType action, List **depends_on_xids)
+{
+	LogicalRepRelMapEntry *relentry;
+	bool		schema_change = (action == LOGICAL_REP_MSG_RELATION);
+
+	Assert(depends_on_xids);
+	Assert(action == LOGICAL_REP_MSG_TRUNCATE ||
+		   action == LOGICAL_REP_MSG_RELATION);
+
+	/* Search for existing entry */
+	relentry = logicalrep_get_relentry(relid);
+
+	Assert(relentry);
+
+	/*
+	 * The relation message does not always indicate that the corresponding
+	 * local relation exists. For example, the relation message may be sent for
+	 * a partition even if the changes are published as root table and the same
+	 * partition may not exists on subscriber. So missing_ok is true for schema
+	 * change, but false for TRUNCATE.
+	 */
+	build_local_dependent_key_info(relentry, schema_change);
+
+	/*
+	 * For both TRUNCATE and schema changes, wait for preceding transactions
+	 * that deleted from the referencing side table.
+	 */
+	foreach_ptr(LogicalRepSubRefKey, refinfo, relentry->local_refkeys)
+	{
+		find_all_dependencies_on_rel(refinfo->fk_remoteid, new_depended_xid,
+									 LOGICALREP_KEY_FOREIGN_KEY,
+									 depends_on_xids);
+
+		check_and_record_rel_dependency(refinfo->fk_remoteid,
+										InvalidTransactionId,
+										depends_on_xids);
+
+		if (schema_change)
+		{
+			LogicalRepRelMapEntry *fkentry;
+
+			fkentry = logicalrep_get_relentry(refinfo->fk_remoteid);
+
+			if (fkentry)
+				fkentry->local_fkeys_valid = false;
+		}
+	}
+
+	/*
+	 * TRUNCATE does not need to wait for referenced-side changes, but schema
+	 * changes do. A replica identity change invalidates previously recorded
+	 * referenced-side dependency entries. Without waiting, a later insert on
+	 * the referencing table might miss the correct dependency on the referenced
+	 * table, so it's safer to wait for preceding transactions on the referenced
+	 * side to finish.
+	 */
+	if (!schema_change)
+		return;
+
+	foreach_ptr(LogicalRepSubFKey, fkinfo, relentry->local_fkeys)
+	{
+		find_all_dependencies_on_rel(fkinfo->ref_remoteid, new_depended_xid,
+									 LOGICALREP_KEY_REFERENCED_KEY,
+									 depends_on_xids);
+
+		check_and_record_rel_dependency(fkinfo->ref_remoteid,
+										InvalidTransactionId,
+										depends_on_xids);
+
+		if (schema_change)
+		{
+			LogicalRepRelMapEntry *refentry;
+
+			refentry = logicalrep_get_relentry(fkinfo->ref_remoteid);
+
+			if (refentry)
+				refentry->local_fkeys_valid = false;
+		}
+	}
+}
+
 /*
  * Check the parallel safety of applying the give action for the relation. If
  * not safe, wait for preceding transactions to finish before proceeding with
@@ -1481,6 +1822,9 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			check_and_record_local_key_dependency(relid, &newtup, false,
 												  new_depended_xid,
 												  &depends_on_xids);
+			check_and_record_fkey_dependency(relid, &newtup, false,
+											 new_depended_xid,
+											 &depends_on_xids);
 			break;
 
 		case LOGICAL_REP_MSG_UPDATE:
@@ -1524,6 +1868,13 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			check_and_record_local_key_dependency(relid, &oldtup, true,
 												  new_depended_xid,
 												  &depends_on_xids);
+
+			check_and_record_fkey_dependency(relid, &newtup, false,
+											 new_depended_xid,
+											 &depends_on_xids);
+			check_and_record_fkey_dependency(relid, &oldtup, true,
+											 new_depended_xid,
+											 &depends_on_xids);
 			break;
 
 		case LOGICAL_REP_MSG_DELETE:
@@ -1533,6 +1884,9 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			check_and_record_local_key_dependency(relid, &oldtup, true,
 												  new_depended_xid,
 												  &depends_on_xids);
+			check_and_record_fkey_dependency(relid, &oldtup, true,
+											 new_depended_xid,
+											 &depends_on_xids);
 			break;
 
 		case LOGICAL_REP_MSG_TRUNCATE:
@@ -1545,10 +1899,15 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			 * modified the same table.
 			 */
 			foreach_int(truncated_relid, remote_relids)
+			{
 				check_and_record_rel_dependency(truncated_relid,
 												new_depended_xid,
 												&depends_on_xids);
 
+				find_frel_dependency(truncated_relid, new_depended_xid,
+									 action, &depends_on_xids);
+			}
+
 			break;
 
 		case LOGICAL_REP_MSG_RELATION:
@@ -1562,6 +1921,12 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			 */
 			check_and_record_rel_dependency(rel->remoteid, new_depended_xid,
 											&depends_on_xids);
+
+			logicalrep_relmap_update(rel);
+
+			find_frel_dependency(rel->remoteid, new_depended_xid, action,
+								 &depends_on_xids);
+
 			break;
 
 		case LOGICAL_REP_MSG_TYPE:
diff --git a/src/include/replication/logicalrelation.h b/src/include/replication/logicalrelation.h
index ec4c9ece571..a917ce2fe86 100644
--- a/src/include/replication/logicalrelation.h
+++ b/src/include/replication/logicalrelation.h
@@ -60,6 +60,11 @@ typedef struct LogicalRepRelMapEntry
 	List	   *local_unique_indexes;
 	bool		local_unique_indexes_valid;
 
+	/* Local foreign keys. Used for dependency tracking */
+	List	   *local_fkeys;
+	List	   *local_refkeys;
+	bool		local_fkeys_valid;
+
 	/*
 	 * Per-operation safety cache for parallel apply. If
 	 * parallel_global_unsafe[action] is true, that action cannot be applied in
@@ -89,6 +94,30 @@ typedef struct LogicalRepSubUniqueIndex
 	bool		nulls_distinct;	/* Whether NULLs are considered distinct */
 } LogicalRepSubUniqueIndex;
 
+/*
+ * Referencing side foreign key information. This is used to track dependencies
+ * between transactions that modify the same referenced and referencing key
+ * values.
+ */
+typedef struct LogicalRepSubFKey
+{
+	Oid			conoid;		/* OID of the FK constraint */
+	LogicalRepRelId ref_remoteid; /* referenced remote relation */
+	List	   *fkattnums_new;	/* new-tuple-safe referencing remote attnums */
+	List	   *fkattnums_old; /* old-tuple-safe referencing remote attnums */
+} LogicalRepSubFKey;
+
+/*
+ * Similar to LogicalRepSubFKey, but for referenced-side primary keys.
+ */
+typedef struct LogicalRepSubRefKey
+{
+	Oid			conoid;		/* OID of the FK constraint */
+	LogicalRepRelId fk_remoteid; /* referencing remote relation */
+	List	   *refattnums_new;	/* new-tuple-safe referenced remote attnums */
+	List	   *refattnums_old; /* old-tuple-safe referenced remote attnums */
+} LogicalRepSubRefKey;
+
 extern void logicalrep_relmap_update(LogicalRepRelation *remoterel);
 extern void logicalrep_partmap_reset_relmap(LogicalRepRelation *remoterel);
 
@@ -100,6 +129,7 @@ extern void logicalrep_rel_close(LogicalRepRelMapEntry *rel,
 								 LOCKMODE lockmode);
 extern void logicalrep_rel_check_parallel_safety(LogicalRepRelMapEntry *entry);
 extern void logicalrep_build_dependent_unique_indexes(LogicalRepRelMapEntry *entry);
+extern void logicalrep_build_dependent_fkeys(LogicalRepRelMapEntry *entry);
 extern bool IsIndexUsableForReplicaIdentityFull(Relation idxrel, AttrMap *attrmap);
 extern Oid	GetRelationIdentityOrPK(Relation rel);
 extern int	logicalrep_get_num_rels(void);
diff --git a/src/test/subscription/t/050_parallel_apply.pl b/src/test/subscription/t/050_parallel_apply.pl
index 9e981bab981..9d52cf660f8 100644
--- a/src/test/subscription/t/050_parallel_apply.pl
+++ b/src/test/subscription/t/050_parallel_apply.pl
@@ -37,6 +37,35 @@ $node_publisher->safe_psql(
     CREATE UNIQUE INDEX tab_toast_ri_index on tab_toast (a, b);
     ALTER TABLE tab_toast REPLICA IDENTITY USING INDEX tab_toast_ri_index;
     INSERT INTO tab_toast(a, b) VALUES(repeat('1234567890', 200), '1234567890');
+
+    CREATE TABLE regress_tab_pk (id int PRIMARY KEY);
+    CREATE TABLE regress_tab_fk (id int PRIMARY KEY, fk int REFERENCES regress_tab_pk (id));
+
+   CREATE TABLE pk_parted (
+        id int PRIMARY KEY,
+        value int
+    ) PARTITION BY RANGE (id);
+
+    CREATE TABLE pk_parted_1 (
+        value int,
+        id int NOT NULL
+    );
+
+    ALTER TABLE pk_parted ATTACH PARTITION pk_parted_1
+    FOR VALUES FROM (1) TO (10);
+
+    CREATE TABLE fk_parted (
+        id int REFERENCES pk_parted(id),
+        value int
+    ) PARTITION BY RANGE (id);
+
+    CREATE TABLE fk_parted_1 (
+        value int,
+        id int
+    );
+
+    ALTER TABLE fk_parted ATTACH PARTITION fk_parted_1
+    FOR VALUES FROM (1) TO (10);
 ));
 $node_publisher->safe_psql('postgres',
     "INSERT INTO regress_tab VALUES (generate_series(1, 10), 'test');");
@@ -78,6 +107,35 @@ $node_subscriber->safe_psql(
     ALTER TABLE tab_toast ALTER COLUMN a SET STORAGE EXTERNAL;
     CREATE UNIQUE INDEX tab_toast_ri_index on tab_toast (a, b);
     ALTER TABLE tab_toast REPLICA IDENTITY USING INDEX tab_toast_ri_index;
+
+    CREATE TABLE regress_tab_pk (id int PRIMARY KEY);
+    CREATE TABLE regress_tab_fk (id int PRIMARY KEY, fk int REFERENCES regress_tab_pk (id));
+
+   CREATE TABLE pk_parted (
+        id int PRIMARY KEY,
+        value int
+    ) PARTITION BY RANGE (id);
+
+    CREATE TABLE pk_parted_1 (
+        value int,
+        id int NOT NULL
+    );
+
+    ALTER TABLE pk_parted ATTACH PARTITION pk_parted_1
+    FOR VALUES FROM (1) TO (10);
+
+    CREATE TABLE fk_parted (
+        id int REFERENCES pk_parted(id),
+        value int
+    ) PARTITION BY RANGE (id);
+
+    CREATE TABLE fk_parted_1 (
+        value int,
+        id int
+    );
+
+    ALTER TABLE fk_parted ATTACH PARTITION fk_parted_1
+    FOR VALUES FROM (1) TO (10);
 ));
 $node_subscriber->safe_psql('postgres',
     "CREATE SUBSCRIPTION regress_sub CONNECTION '$publisher_connstr' PUBLICATION regress_pub;");
@@ -651,4 +709,276 @@ $node_subscriber->safe_psql('postgres', "DROP INDEX local_unique_idx_expr;");
 $node_publisher->safe_psql('postgres', "TRUNCATE TABLE regress_tab;");
 $node_publisher->wait_for_catchup('regress_sub');
 
+##################################################
+# Test that the dependency tracking works correctly for foreign keys on
+# subscriber during parallel apply.
+##################################################
+
+# Test that when receiving table schema information for a referencing table, the
+# subscriber correctly checks for dependencies on the referenced table and waits
+# for its changes to complete.
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+# Enable foreign triggers on subscriber
+my $pk_fk_trigger = $node_subscriber->safe_psql('postgres', qq[
+    SELECT tgname
+    FROM pg_trigger
+    WHERE tgrelid = 'regress_tab_pk'::regclass
+      AND tgconstraint > 0
+    LIMIT 1
+]);
+chomp($pk_fk_trigger);
+
+my $fk_fk_trigger = $node_subscriber->safe_psql('postgres', qq[
+    SELECT tgname
+    FROM pg_trigger
+    WHERE tgrelid = 'regress_tab_fk'::regclass
+      AND tgconstraint > 0
+    LIMIT 1
+]);
+chomp($fk_fk_trigger);
+
+$node_subscriber->safe_psql('postgres',
+    qq[ALTER TABLE regress_tab_pk ENABLE REPLICA TRIGGER "$pk_fk_trigger";
+    ALTER TABLE regress_tab_fk ENABLE REPLICA TRIGGER "$fk_fk_trigger";]);
+
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab_pk VALUES (2);");
+
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+$offset = -s $node_subscriber->logfile;
+
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab_fk VALUES (1, 2);");
+
+# Verify the dependency is detected on referenced table schema information
+$str = $node_subscriber->wait_for_log(qr/found conflicting change on table [1-9][0-9]+ from ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /found conflicting change on table [1-9][0-9]+ from ([1-9][0-9]+)/;
+
+# Verify the parallel worker waits for the same transaction
+$node_subscriber->wait_for_log(qr/wait for depended xid $xid/, $offset);
+
+ok(1, "referenced table dependency detected for parallel apply");
+
+# Wakeup the parallel worker
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+# Verify the streamed transaction can be applied
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+$node_publisher->wait_for_catchup('regress_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab_pk");
+is ($result, 1, 'insert is replicated to referenced table on subscriber');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab_fk");
+is ($result, 1, 'insert is replicated to referencing table on subscriber');
+
+# INSERT - INSERT case: Tx-1 inserts referenced tuple and Tx-2 inserts
+# referencing tuple.
+
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab_pk VALUES (3);");
+
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+$offset = -s $node_subscriber->logfile;
+
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab_fk VALUES (2, 3);");
+
+# Verify the dependency for referenced key change is detected
+$str = $node_subscriber->wait_for_log(qr/found conflicting referenced key change on table [1-9][0-9]+ from ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /found conflicting referenced key change on table [1-9][0-9]+ from ([1-9][0-9]+)/;
+
+# Verify the parallel worker waits for the same transaction
+$node_subscriber->wait_for_log(qr/wait for depended xid $xid/, $offset);
+
+ok(1, "referenced key dependency detected for parallel apply");
+
+# Wakeup the parallel worker
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+# Verify the streamed transaction can be applied
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+$node_publisher->wait_for_catchup('regress_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab_pk");
+is ($result, 2, 'insert is replicated to referenced table on subscriber');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab_fk");
+is ($result, 2, 'insert is replicated to referencing table on subscriber');
+
+# DELETE - DELETE case: Tx-1 deletes referencing tuple and Tx-2 deletes
+# referenced tuple.
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+$node_publisher->safe_psql('postgres',
+    "DELETE FROM regress_tab_fk WHERE id = 1;");
+
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+$offset = -s $node_subscriber->logfile;
+
+$node_publisher->safe_psql('postgres',
+    "DELETE FROM regress_tab_pk WHERE id = 2;");
+
+$str = $node_subscriber->wait_for_log(qr/found conflicting foreign key change on table [1-9][0-9]+ from ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /found conflicting foreign key change on table [1-9][0-9]+ from ([1-9][0-9]+)/;
+
+$node_subscriber->wait_for_log(qr/wait for depended xid $xid/, $offset);
+
+ok(1, "foreign key dependency detected for parallel apply");
+
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+$node_publisher->wait_for_catchup('regress_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab_pk");
+is ($result, 1, 'delete is replicated to referenced table on subscriber');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab_fk");
+is ($result, 1, 'delete is replicated to referencing table on subscriber');
+
+##################################################
+# Test that the dependency tracking works correctly for foreign keys when both
+# the referenced and referencing tables are partitioned.
+##################################################
+
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+my $part_pk_trigger = $node_subscriber->safe_psql('postgres', qq[
+        SELECT tgname
+        FROM pg_trigger
+        WHERE tgrelid = 'pk_parted_1'::regclass
+            AND tgconstraint > 0
+        LIMIT 1
+]);
+chomp($part_pk_trigger);
+
+my $part_fk_trigger = $node_subscriber->safe_psql('postgres', qq[
+        SELECT tgname
+        FROM pg_trigger
+        WHERE tgrelid = 'fk_parted_1'::regclass
+            AND tgconstraint > 0
+        LIMIT 1
+]);
+chomp($part_fk_trigger);
+
+$node_subscriber->safe_psql('postgres',
+        qq[ALTER TABLE pk_parted_1 ENABLE REPLICA TRIGGER "$part_pk_trigger";
+        ALTER TABLE fk_parted_1 ENABLE REPLICA TRIGGER "$part_fk_trigger";]);
+
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO pk_parted_1(id, value) VALUES (1, 10);");
+
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+$offset = -s $node_subscriber->logfile;
+
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO fk_parted_1(id, value) VALUES (1, 10);");
+
+# Verify the dependency is detected on referenced table schema information
+$str = $node_subscriber->wait_for_log(qr/found conflicting change on table [1-9][0-9]+ from ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /found conflicting change on table [1-9][0-9]+ from ([1-9][0-9]+)/;
+
+# Verify the parallel worker waits for the same transaction
+$node_subscriber->wait_for_log(qr/wait for depended xid $xid/, $offset);
+
+ok(1, "partitioned referenced table dependency detected for parallel apply");
+
+# Wakeup the parallel worker
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+# Verify the streamed transaction can be applied
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+$node_publisher->wait_for_catchup('regress_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM pk_parted_1");
+is ($result, 1, 'insert is replicated to referenced table on subscriber');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM fk_parted_1");
+is ($result, 1, 'insert is replicated to referencing table on subscriber');
+
+# INSERT - INSERT case: Tx-1 inserts referenced tuple and Tx-2 inserts
+# referencing tuple.
+
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO pk_parted_1(id, value) VALUES (2, 20);");
+
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+$offset = -s $node_subscriber->logfile;
+
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO fk_parted_1(id, value) VALUES (2, 20);");
+
+# Verify the dependency for referenced key change is detected
+$str = $node_subscriber->wait_for_log(qr/found conflicting referenced key change on table [1-9][0-9]+ from ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /found conflicting referenced key change on table [1-9][0-9]+ from ([1-9][0-9]+)/;
+
+# Verify the parallel worker waits for the same transaction
+$node_subscriber->wait_for_log(qr/wait for depended xid $xid/, $offset);
+
+ok(1, "partitioned referenced key dependency detected for parallel apply");
+
+# Wakeup the parallel worker
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+# Verify the streamed transaction can be applied
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+$node_publisher->wait_for_catchup('regress_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM pk_parted_1");
+is ($result, 2, 'insert is replicated to referenced table on subscriber');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM fk_parted_1");
+is ($result, 2, 'insert is replicated to referencing table on subscriber');
+
 done_testing();
-- 
2.43.0



  [application/octet-stream] v21-0007-Handle-unsafe-parallel-apply-cases-due-to-non-im.patch (20.1K, ../../TY4PR01MB177180394D8E9C6D263E117E494FE2@TY4PR01MB17718.jpnprd01.prod.outlook.com/4-v21-0007-Handle-unsafe-parallel-apply-cases-due-to-non-im.patch)
  download | inline diff:
From 28cff2370f49d34ca742254656e778dbef1e1fb5 Mon Sep 17 00:00:00 2001
From: Zhijie Hou <[email protected]>
Date: Tue, 9 Jun 2026 18:45:02 +0800
Subject: [PATCH v21 7/9] Handle unsafe parallel apply cases due to
 non-immutable functions

This patch adds table-level parallel safety checks that mark operations as
unsafe when a table has triggers executing non-immutable functions, or when
subscriber-only column defaults contain non-immutable functions.

Safety is determined by the worker that actually applies the changes, rather
than solely by the leader. Leader-only evaluation is unsafe because the table
could be altered before changes are dispatched to a parallel worker. Without
re-validation by the parallel worker, it might incorrectly assume the relation
is safe for parallel apply.

When an unsafe operation is encountered, the worker waits for the last
parallelized transaction to commit before proceeding.
---
 .../replication/logical/applyparallelworker.c |  7 +-
 src/backend/replication/logical/relation.c    | 98 +++++++++++++++++++
 src/backend/replication/logical/worker.c      | 85 ++++++++++++++--
 src/include/replication/logicalrelation.h     | 28 ++++++
 src/include/replication/worker_internal.h     |  6 +-
 src/test/subscription/t/050_parallel_apply.pl | 71 ++++++++++++++
 6 files changed, 286 insertions(+), 9 deletions(-)

diff --git a/src/backend/replication/logical/applyparallelworker.c b/src/backend/replication/logical/applyparallelworker.c
index 6e478a41e73..3e436b518bd 100644
--- a/src/backend/replication/logical/applyparallelworker.c
+++ b/src/backend/replication/logical/applyparallelworker.c
@@ -667,13 +667,17 @@ pa_launch_parallel_worker(void)
 /*
  * Allocate a parallel apply worker that will be used for the specified xid.
  *
+ * preceding_xid is the transaction received before the current one; pass
+ * InvalidTransactionId if none exists.
+ *
  * We first try to get an available worker from the pool, if any and then try
  * to launch a new worker. On successful allocation, remember the worker
  * information in the hash table so that we can get it later for processing the
  * streaming changes.
  */
 void
-pa_allocate_worker(TransactionId xid, bool stream_txn)
+pa_allocate_worker(TransactionId xid, TransactionId preceding_xid,
+				   bool stream_txn)
 {
 	bool		found;
 	ParallelApplyWorkerInfo *winfo = NULL;
@@ -710,6 +714,7 @@ pa_allocate_worker(TransactionId xid, bool stream_txn)
 	SpinLockAcquire(&winfo->shared->mutex);
 	winfo->shared->xact_state = PARALLEL_TRANS_UNKNOWN;
 	winfo->shared->xid = xid;
+	winfo->shared->preceding_xid = preceding_xid;
 	SpinLockRelease(&winfo->shared->mutex);
 
 	winfo->in_use = true;
diff --git a/src/backend/replication/logical/relation.c b/src/backend/replication/logical/relation.c
index c5a83b095fa..6d3e1827ae2 100644
--- a/src/backend/replication/logical/relation.c
+++ b/src/backend/replication/logical/relation.c
@@ -21,11 +21,16 @@
 #include "access/genam.h"
 #include "access/table.h"
 #include "catalog/namespace.h"
+#include "catalog/pg_proc.h"
 #include "catalog/pg_subscription_rel.h"
+#include "catalog/pg_trigger.h"
+#include "commands/trigger.h"
 #include "executor/executor.h"
 #include "nodes/makefuncs.h"
+#include "optimizer/optimizer.h"
 #include "replication/logicalrelation.h"
 #include "replication/worker_internal.h"
+#include "rewrite/rewriteHandler.h"
 #include "utils/inval.h"
 #include "utils/lsyscache.h"
 #include "utils/syscache.h"
@@ -82,6 +87,7 @@ logicalrep_relmap_invalidate_cb(Datum arg, Oid reloid)
 			if (entry->localreloid == reloid)
 			{
 				entry->localrelvalid = false;
+				entry->parallel_safety_valid = false;
 				hash_seq_term(&status);
 				break;
 			}
@@ -95,7 +101,10 @@ logicalrep_relmap_invalidate_cb(Datum arg, Oid reloid)
 		hash_seq_init(&status, LogicalRepRelMap);
 
 		while ((entry = (LogicalRepRelMapEntry *) hash_seq_search(&status)) != NULL)
+		{
 			entry->localrelvalid = false;
+			entry->parallel_safety_valid = false;
+		}
 	}
 }
 
@@ -394,6 +403,7 @@ logicalrep_rel_open(LogicalRepRelId remoteid, LOCKMODE lockmode)
 		{
 			/* Table was renamed or dropped. */
 			entry->localrelvalid = false;
+			entry->parallel_safety_valid = false;
 		}
 		else if (!entry->localrelvalid)
 		{
@@ -520,6 +530,90 @@ logicalrep_rel_close(LogicalRepRelMapEntry *rel, LOCKMODE lockmode)
 	rel->localrel = NULL;
 }
 
+/*
+ * Check whether changes on this relation can be applied in parallel.
+ *
+ * Parallel apply is unsafe if the table has any user-defined functions that may
+ * be executed during change application (e.g., in triggers, defaults, or
+ * constraint expressions). In theory, we should check the volatility of all
+ * such functions.
+ *
+ * Note that we do not check user-defined CHECK constraints here, as PostgreSQL
+ * already assumes they are immutable; we follow that same rule.
+ *
+ * If any mutable function is found, the relation is marked as globally unsafe,
+ * and the result is cached.
+ */
+void
+logicalrep_rel_check_parallel_safety(LogicalRepRelMapEntry *entry)
+{
+	Relation	localrel = entry->localrel;
+	TriggerDesc *trigdesc = localrel->trigdesc;
+	int			ntriggers = trigdesc ? trigdesc->numtriggers : 0;
+	TupleDesc	desc = RelationGetDescr(localrel);
+
+	if (entry->parallel_safety_valid)
+		return;
+
+	memset(entry->parallel_global_unsafe, 0,
+		   sizeof(entry->parallel_global_unsafe));
+
+	/* Seek triggers one by one to see the volatility */
+	for (int i = 0; i < ntriggers; i++)
+	{
+		Trigger    *trigger = &trigdesc->triggers[i];
+
+		Assert(OidIsValid(trigger->tgfoid));
+
+		/* Skip if the trigger is internal (e.g., fkey triggers) */
+		if (trigger->tgisinternal)
+			continue;
+
+		/* Skip if the trigger is not enabled for logical replication */
+		if (trigger->tgenabled == TRIGGER_DISABLED ||
+			trigger->tgenabled == TRIGGER_FIRES_ON_ORIGIN)
+			continue;
+
+		/* Check the volatility of the trigger. Exit if it is not immutable */
+		if (func_volatile(trigger->tgfoid) != PROVOLATILE_IMMUTABLE)
+		{
+			if (TRIGGER_FOR_INSERT(trigger->tgtype))
+				entry->parallel_global_unsafe[LRPA_INSERT] = true;
+			else if (TRIGGER_FOR_UPDATE(trigger->tgtype))
+				entry->parallel_global_unsafe[LRPA_UPDATE] = true;
+			else if (TRIGGER_FOR_DELETE(trigger->tgtype))
+				entry->parallel_global_unsafe[LRPA_DELETE] = true;
+			else if (TRIGGER_FOR_TRUNCATE(trigger->tgtype))
+				entry->parallel_global_unsafe[LRPA_TRUNCATE] = true;
+		}
+	}
+
+	/* Check column defaults for mutable functions */
+	for (int attnum = 0; attnum < desc->natts; attnum++)
+	{
+		CompactAttribute *cattr = TupleDescCompactAttr(desc, attnum);
+		Expr	   *defexpr;
+
+		if (cattr->attisdropped || cattr->attgenerated)
+			continue;
+
+		/* Skip columns whose values come from remote change */
+		if (entry->attrmap->attnums[attnum] >= 0)
+			continue;
+
+		defexpr = (Expr *) build_column_default(entry->localrel, attnum + 1);
+
+		if (contain_mutable_functions_after_planning(defexpr))
+		{
+			/* Only INSERT operations are affected by column defaults */
+			entry->parallel_global_unsafe[LRPA_INSERT] = true;
+			break;
+		}
+	}
+
+	entry->parallel_safety_valid = true;
+}
+
 /*
  * Partition cache: look up partition LogicalRepRelMapEntry's
  *
@@ -553,6 +647,7 @@ logicalrep_partmap_invalidate_cb(Datum arg, Oid reloid)
 			if (entry->relmapentry.localreloid == reloid)
 			{
 				entry->relmapentry.localrelvalid = false;
+				entry->relmapentry.parallel_safety_valid = false;
 				hash_seq_term(&status);
 				break;
 			}
@@ -566,7 +661,10 @@ logicalrep_partmap_invalidate_cb(Datum arg, Oid reloid)
 		hash_seq_init(&status, LogicalRepPartMap);
 
 		while ((entry = (LogicalRepPartMapEntry *) hash_seq_search(&status)) != NULL)
+		{
 			entry->relmapentry.localrelvalid = false;
+			entry->relmapentry.parallel_safety_valid = false;
+		}
 	}
 }
 
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 0afba345713..18d71ceabe6 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -1193,6 +1193,54 @@ check_and_record_rel_dependency(LogicalRepRelId relid,
 		relentry->last_depended_xid = new_depended_xid;
 }
 
+/*
+ * Check the parallel safety of applying the give action for the relation. If
+ * not safe, wait for preceding transactions to finish before proceeding with
+ * the current change.
+ *
+ * See logicalrep_rel_check_parallel_safety for details on how the safety is
+ * determined.
+ */
+static void
+check_relation_parallel_apply_safety(LogicalRepRelMapEntry *relentry,
+									 LogicalRepParallelAction action)
+{
+	/* Do not check parallel apply safety for streamed transactions */
+	if (in_streamed_transaction)
+		return;
+
+	/* Parallel apply only involves the leader and parallel apply workers */
+	if (!am_leader_apply_worker() && !am_parallel_apply_worker())
+		return;
+
+	/*
+	 * For partitioned tables, we only need to care if the target partition is
+	 * parallel apply safe or not.
+	 */
+	if (relentry->localrel->rd_rel->relkind == RELKIND_PARTITIONED_TABLE)
+		return;
+
+	logicalrep_rel_check_parallel_safety(relentry);
+
+	/* Return if the action is safe for parallel apply */
+	if (!relentry->parallel_global_unsafe[action])
+		return;
+
+	elog(DEBUG1, "found parallel unsafe change on table %u for action %d",
+		 relentry->remoterel.remoteid, action);
+
+	/*
+	 * Wait for preceding transactions to finish before proceeding and reset the
+	 * preceding_xid to InvalidTransactionId to avoid waiting for the same
+	 * transaction again in the next change.
+	 */
+	if (TransactionIdIsValid(last_parallelized_remote_xid))
+	{
+		pa_wait_for_depended_transaction(last_parallelized_remote_xid);
+		last_parallelized_remote_xid = InvalidTransactionId;
+	}
+}
+
 /*
  * Check dependencies related to the current change by determining if the
  * modification impacts the same row or table as another ongoing transaction.
@@ -2048,7 +2096,7 @@ apply_handle_begin(StringInfo s)
 
 	maybe_start_skipping_changes(begin_data.final_lsn);
 
-	pa_allocate_worker(remote_xid, false);
+	pa_allocate_worker(remote_xid, last_parallelized_remote_xid, false);
 
 	apply_action = get_transaction_apply_action(remote_xid, &winfo);
 
@@ -2080,6 +2128,8 @@ apply_handle_begin(StringInfo s)
 			/* Hold the lock until the end of the transaction. */
 			pa_lock_transaction(MyParallelShared->xid, AccessExclusiveLock);
 			pa_set_xact_state(MyParallelShared, PARALLEL_TRANS_STARTED);
+
+			last_parallelized_remote_xid = MyParallelShared->preceding_xid;
 			break;
 
 		default:
@@ -2232,6 +2282,8 @@ apply_handle_commit(StringInfo s)
 			pa_commit_transaction();
 
 			pa_unlock_transaction(remote_xid, AccessExclusiveLock);
+
+			last_parallelized_remote_xid = InvalidTransactionId;
 			break;
 
 		default:
@@ -2283,7 +2335,7 @@ apply_handle_begin_prepare(StringInfo s)
 
 	maybe_start_skipping_changes(begin_data.prepare_lsn);
 
-	pa_allocate_worker(remote_xid, false);
+	pa_allocate_worker(remote_xid, last_parallelized_remote_xid, false);
 
 	apply_action = get_transaction_apply_action(remote_xid, &winfo);
 
@@ -2500,6 +2552,8 @@ apply_handle_prepare(StringInfo s)
 			pa_unlock_transaction(MyParallelShared->xid, AccessExclusiveLock);
 
 			pa_reset_subtrans();
+
+			last_parallelized_remote_xid = InvalidTransactionId;
 			break;
 
 		default:
@@ -2926,7 +2980,7 @@ apply_handle_stream_start(StringInfo s)
 
 	/* Try to allocate a worker for the streaming transaction. */
 	if (first_segment)
-		pa_allocate_worker(stream_xid, true);
+		pa_allocate_worker(stream_xid, last_parallelized_remote_xid, true);
 
 	apply_action = get_transaction_apply_action(stream_xid, &winfo);
 
@@ -3873,6 +3927,9 @@ apply_handle_insert(StringInfo s)
 	/* Set relation for error callback */
 	apply_error_callback_arg.rel = rel;
 
+	/* Check if the relation is safe for parallel apply */
+	check_relation_parallel_apply_safety(rel, LRPA_INSERT);
+
 	/* Initialize the executor state. */
 	edata = create_edata_for_relation(rel);
 	estate = edata->estate;
@@ -4030,6 +4087,9 @@ apply_handle_update(StringInfo s)
 	/* Check if we can do the update. */
 	check_relation_updatable(rel);
 
+	/* Check if the relation is safe for parallel apply */
+	check_relation_parallel_apply_safety(rel, LRPA_UPDATE);
+
 	/*
 	 * Make sure that any user-supplied code runs as the table owner, unless
 	 * the user has opted out of that behavior.
@@ -4254,6 +4314,9 @@ apply_handle_delete(StringInfo s)
 	/* Check if we can do the delete. */
 	check_relation_updatable(rel);
 
+	/* Check if the relation is safe for parallel apply */
+	check_relation_parallel_apply_safety(rel, LRPA_DELETE);
+
 	/*
 	 * Make sure that any user-supplied code runs as the table owner, unless
 	 * the user has opted out of that behavior.
@@ -4623,22 +4686,25 @@ apply_handle_tuple_routing(ApplyExecutionData *edata,
 	}
 	MemoryContextSwitchTo(oldctx);
 
+	part_entry = logicalrep_partition_open(relmapentry, partrel,
+										   attrmap);
+
 	/* Check if we can do the update or delete on the leaf partition. */
 	if (operation == CMD_UPDATE || operation == CMD_DELETE)
-	{
-		part_entry = logicalrep_partition_open(relmapentry, partrel,
-											   attrmap);
 		check_relation_updatable(part_entry);
-	}
 
 	switch (operation)
 	{
 		case CMD_INSERT:
+			check_relation_parallel_apply_safety(part_entry,
+												 LRPA_INSERT);
 			apply_handle_insert_internal(edata, partrelinfo,
 										 remoteslot_part);
 			break;
 
 		case CMD_DELETE:
+			check_relation_parallel_apply_safety(part_entry,
+												 LRPA_DELETE);
 			apply_handle_delete_internal(edata, partrelinfo,
 										 remoteslot_part,
 										 part_entry->localindexoid);
@@ -4660,6 +4726,9 @@ apply_handle_tuple_routing(ApplyExecutionData *edata,
 				EPQState	epqstate;
 				ConflictTupleInfo conflicttuple = {0};
 
+				check_relation_parallel_apply_safety(part_entry,
+													 LRPA_UPDATE);
+
 				/* Get the matching local tuple from the partition. */
 				found = FindReplTupleInLocalRel(edata, partrel,
 												&part_entry->remoterel,
@@ -4890,6 +4959,8 @@ apply_handle_truncate(StringInfo s)
 			continue;
 		}
 
+		check_relation_parallel_apply_safety(rel, LRPA_TRUNCATE);
+
 		remote_rels = lappend(remote_rels, rel);
 		TargetPrivilegesCheck(rel->localrel, ACL_TRUNCATE);
 		rels = lappend(rels, rel->localrel);
diff --git a/src/include/replication/logicalrelation.h b/src/include/replication/logicalrelation.h
index cd852337165..439243be742 100644
--- a/src/include/replication/logicalrelation.h
+++ b/src/include/replication/logicalrelation.h
@@ -16,6 +16,16 @@
 #include "catalog/index.h"
 #include "replication/logicalproto.h"
 
+typedef enum LogicalRepParallelAction
+{
+	LRPA_INSERT,
+	LRPA_UPDATE,
+	LRPA_DELETE,
+	LRPA_TRUNCATE
+} LogicalRepParallelAction;
+
+#define LRPA_ACTION_COUNT (LRPA_TRUNCATE + 1)
+
 typedef struct LogicalRepRelMapEntry
 {
 	LogicalRepRelation remoterel;	/* key is remoterel.remoteid */
@@ -45,6 +55,23 @@ typedef struct LogicalRepRelMapEntry
 	 * applying (see check_dependency_on_rel).
 	 */
 	TransactionId last_depended_xid;
+
+	/*
+	 * Per-operation safety cache for parallel apply. If
+	 * parallel_global_unsafe[action] is true, that action cannot be applied in
+	 * parallel for this relation.
+	 *
+	 * This cache must be computed by the worker that actually applies changes
+	 * (via logicalrep_rel_check_parallel_safety), rather than solely by the
+	 * leader.
+	 *
+	 * Relying on the leader alone is unsafe because the table could be altered
+	 * before changes are dispatched to a parallel worker. Without re-validation
+	 * by the parallel worker, it might incorrectly assume that parallel apply
+	 * is safe for this relation.
+	 */
+	bool		parallel_safety_valid;
+	bool		parallel_global_unsafe[LRPA_ACTION_COUNT];
 } LogicalRepRelMapEntry;
 
 extern void logicalrep_relmap_update(LogicalRepRelation *remoterel);
@@ -56,6 +83,7 @@ extern LogicalRepRelMapEntry *logicalrep_partition_open(LogicalRepRelMapEntry *r
 														Relation partrel, AttrMap *map);
 extern void logicalrep_rel_close(LogicalRepRelMapEntry *rel,
 								 LOCKMODE lockmode);
+extern void logicalrep_rel_check_parallel_safety(LogicalRepRelMapEntry *entry);
 extern bool IsIndexUsableForReplicaIdentityFull(Relation idxrel, AttrMap *attrmap);
 extern Oid	GetRelationIdentityOrPK(Relation rel);
 extern int	logicalrep_get_num_rels(void);
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index 3dde899f465..4f6d699492f 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -205,6 +205,9 @@ typedef struct ParallelApplyWorkerShared
 	 */
 	dsa_handle	parallel_apply_dsa_handle;
 	dshash_table_handle parallelized_txns_handle;
+
+	/* The remote transaction ID received before the current transaction */
+	TransactionId preceding_xid;
 } ParallelApplyWorkerShared;
 
 /*
@@ -353,7 +356,8 @@ extern void apply_error_callback(void *arg);
 extern void set_apply_error_context_origin(char *originname);
 
 /* Parallel apply worker setup and interactions */
-extern void pa_allocate_worker(TransactionId xid, bool stream_txn);
+extern void pa_allocate_worker(TransactionId xid, TransactionId preceding_xid,
+							   bool stream_txn);
 extern ParallelApplyWorkerInfo *pa_find_worker(TransactionId xid);
 extern XLogRecPtr pa_get_last_commit_end(TransactionId xid, bool delete_entry,
 										 bool *skipped_write);
diff --git a/src/test/subscription/t/050_parallel_apply.pl b/src/test/subscription/t/050_parallel_apply.pl
index a6343d57bc5..c90a72682e5 100644
--- a/src/test/subscription/t/050_parallel_apply.pl
+++ b/src/test/subscription/t/050_parallel_apply.pl
@@ -456,4 +456,75 @@ $result =
   $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab");
 is ($result, 5011, 'inserts are replicated to subscriber');
 
+##################################################
+# Test that mutable user-defined triggers force apply-time waiting so
+# conflicting trigger side effects are serialized.
+##################################################
+
+# Truncate the data for upcoming tests
+$node_publisher->safe_psql('postgres', "TRUNCATE TABLE regress_tab;");
+$node_publisher->wait_for_catchup('regress_sub');
+
+$node_subscriber->safe_psql('postgres', qq[
+    CREATE OR REPLACE FUNCTION regress_trigger_guard_fn()
+    RETURNS trigger
+    LANGUAGE plpgsql
+    AS \$\$
+    BEGIN
+        IF NOT EXISTS (SELECT 1 FROM public.regress_tab WHERE id = 1) THEN
+            UPDATE public.regress_tab SET id = 2 WHERE id = 1;
+        END IF;
+        RETURN NEW;
+    END;
+    \$\$;
+
+    CREATE TRIGGER regress_trigger_guard_tg
+    BEFORE INSERT ON regress_tab
+    FOR EACH ROW
+    EXECUTE FUNCTION regress_trigger_guard_fn();
+]);
+
+# Ensure trigger fires during logical replication apply.
+$node_subscriber->safe_psql('postgres',
+	"ALTER TABLE regress_tab ENABLE REPLICA TRIGGER regress_trigger_guard_tg;"
+);
+
+# Hold the first parallel worker just before commit.
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+# TX-1: first insert; worker pauses at before-commit injection point.
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (1);");
+
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+$offset = -s $node_subscriber->logfile;
+
+# TX-2: second insert. For unsafe trigger tables, this must wait before apply.
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (2);");
+
+$node_subscriber->wait_for_log(qr/found parallel unsafe change on table [1-9][0-9]* for action [0-9]+/, $offset);
+
+$str = $node_subscriber->wait_for_log(qr/wait for depended xid ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /wait for depended xid ([1-9][0-9]+)/;
+
+ok(1, "mutable trigger relation waits before apply in parallel mode");
+
+# Resume workers.
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+$node_publisher->wait_for_catchup('regress_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab");
+is ($result, 2, 'inserts are replicated to subscriber');
+
 done_testing();
-- 
2.43.0



  [application/octet-stream] v21-0008-Support-dependency-tracking-via-local-unique-ind.patch (39.8K, ../../TY4PR01MB177180394D8E9C6D263E117E494FE2@TY4PR01MB17718.jpnprd01.prod.outlook.com/5-v21-0008-Support-dependency-tracking-via-local-unique-ind.patch)
  download | inline diff:
From ad1cdc213f4ac7764295d2de1038077ac96ea326 Mon Sep 17 00:00:00 2001
From: Zhijie Hou <[email protected]>
Date: Thu, 9 Jul 2026 15:43:06 +0800
Subject: [PATCH v21 8/9] Support dependency tracking via local unique indexes

This patch tracks dependencies on transactions that modify the same local unique
key. Even if the replica identity keys differ, unique indexes can still cause
conflicts. This is necessary to prevent unexpected errors. For example:

  TX-1: DELETE row (1,2) with replica identity key (1,2) and unique key (2)
  TX-2: INSERT row (3,2) with replica identity key (3,2) and unique key (2)

If applied in parallel, TX-2's INSERT could be applied before TX-1's DELETE,
leading to a unique index violation error.

We do not track dependencies for INSERT and UPDATE that conflict on a new
unique key value, since such conflicts would cause an error even in serial
mode. Instead, we only track dependencies involving old tuples (from DELETE
or UPDATE) and require INSERT and UPDATE transactions that target the same
unique key to wait for them.

Note that the old tuple of an UPDATE or DELETE may not include the unique key
column if that column is not part of the replica identity columns on the
publisher. In such cases, we only use the unique columns that are part of the
replica identity keys for dependency tracking, which may lead to false
positives. For example, consider a unique index defined as UNIQUE (a, b),
where only b is part of the replica identity keys:

  TX-1: DELETE row (1,2) TX-2: INSERT row (3,2)

If applied in parallel, both transactions will be treated as dependent
because they modify the same unique key value (b=2), even though they
actually modify different unique keys. This is acceptable because it is still
better than completely disallowing parallelism for these transactions.

In the worst case, if none of the unique index columns are part of the
replica identity keys, we treat all transactions that modify the same table
as dependent and disallow parallelism for that table.

XXX We could consider requesting the publisher to include unique key columns
in the old tuple of UPDATE or DELETE when they are not part of the replica
identity keys. This would reduce false positives, but would require changes
on the publisher side and increase disk (WAL size) and network data. For now,
we choose not to implement this. An alternative approach is to provide an
option to skip tracking dependencies on unique keys that are not part of the
replica identity keys. This could be useful for users who prefer higher
parallelism and experience few conflicts.

Note that the local unique key could change after dependency checking and
before applying the change. However, to centralize tracking and keep it
simple, we still perform this check only in the leader apply worker. This is
acceptable because in the worst case, the parallel worker will report an
error and restart the transaction using the latest index information.
---
 .../replication/logical/applyparallelworker.c |  50 +++
 src/backend/replication/logical/relation.c    | 179 ++++++++
 src/backend/replication/logical/worker.c      | 414 +++++++++++++-----
 src/backend/storage/lmgr/deadlock.c           |   1 -
 src/include/replication/logicalrelation.h     |  16 +
 src/test/subscription/t/050_parallel_apply.pl | 124 ++++++
 src/tools/pgindent/typedefs.list              |   2 +
 7 files changed, 673 insertions(+), 113 deletions(-)

diff --git a/src/backend/replication/logical/applyparallelworker.c b/src/backend/replication/logical/applyparallelworker.c
index 3e436b518bd..1f1a75b5019 100644
--- a/src/backend/replication/logical/applyparallelworker.c
+++ b/src/backend/replication/logical/applyparallelworker.c
@@ -174,6 +174,56 @@
  * both are allowed to apply in parallel, TX-2's DELETE could be applied before
  * TX-1's INSERT, resulting in a delete_missing conflict.
  *
+ * Beyond replica identity keys, we also track dependencies on transactions that
+ * modify the same local unique key. Even if the replica identity keys differ,
+ * unique indexes can still cause conflicts. This is necessary to prevent
+ * unexpected errors. For example:
+ *
+ *   TX-1: DELETE row (1,2) with replica identity key (1,2) and unique key (2)
+ *   TX-2: INSERT row (3,2) with replica identity key (3,2) and unique key (2)
+ *
+ * If applied in parallel, TX-2's INSERT could be applied before TX-1's DELETE,
+ * leading to a unique index violation error.
+ *
+ * We do not track dependencies for INSERT and UPDATE that conflict on a new
+ * unique key value, since such conflicts would cause an error even in serial
+ * mode. Instead, we only track dependencies involving old tuples (from DELETE
+ * or UPDATE) and require INSERT and UPDATE transactions that target the same
+ * unique key to wait for them.
+ *
+ * Note that the old tuple of an UPDATE or DELETE may not include the unique key
+ * column if that column is not part of the replica identity columns on the
+ * publisher. In such cases, we only use the unique columns that are part of the
+ * replica identity keys for dependency tracking, which may lead to false
+ * positives. For example, consider a unique index defined as UNIQUE (a, b),
+ * where only b is part of the replica identity keys:
+ *
+ *   TX-1: DELETE row (1,2) TX-2: INSERT row (3,2)
+ *
+ * If applied in parallel, both transactions will be treated as dependent
+ * because they modify the same unique key value (b=2), even though they
+ * actually modify different unique keys. This is acceptable because it is still
+ * better than completely disallowing parallelism for these transactions.
+ *
+ * In the worst case, if none of the unique index columns are part of the
+ * replica identity keys, we treat all transactions that modify the same table
+ * as dependent and disallow parallelism for that table.
+ *
+ * XXX We could consider requesting the publisher to include unique key columns
+ * in the old tuple of UPDATE or DELETE when they are not part of the replica
+ * identity keys. This would reduce false positives, but would require changes
+ * on the publisher side and increase disk (WAL size) and network data. For now,
+ * we choose not to implement this. An alternative approach is to provide an
+ * option to skip tracking dependencies on unique keys that are not part of the
+ * replica identity keys. This could be useful for users who prefer higher
+ * parallelism and experience few conflicts.
+ *
+ * Note that the local unique key could change after dependency checking and
+ * before applying the change. However, to centralize tracking and keep it
+ * simple, we still perform this check only in the leader apply worker. This is
+ * acceptable because in the worst case, the parallel worker will report an
+ * error and restart the transaction using the latest index information.
+ *
  * Commit order
  * ------------
  * We preserve publisher commit order for all transactions for two reasons:
diff --git a/src/backend/replication/logical/relation.c b/src/backend/replication/logical/relation.c
index 6d3e1827ae2..1572990ee18 100644
--- a/src/backend/replication/logical/relation.c
+++ b/src/backend/replication/logical/relation.c
@@ -88,6 +88,7 @@ logicalrep_relmap_invalidate_cb(Datum arg, Oid reloid)
 			{
 				entry->localrelvalid = false;
 				entry->parallel_safety_valid = false;
+				entry->local_unique_indexes_valid = false;
 				hash_seq_term(&status);
 				break;
 			}
@@ -104,6 +105,7 @@ logicalrep_relmap_invalidate_cb(Datum arg, Oid reloid)
 		{
 			entry->localrelvalid = false;
 			entry->parallel_safety_valid = false;
+			entry->local_unique_indexes_valid = false;
 		}
 	}
 }
@@ -135,6 +137,21 @@ logicalrep_relmap_init(void)
 								  (Datum) 0);
 }
 
+/*
+ * Release local index list
+ */
+static void
+free_local_unique_indexes(LogicalRepRelMapEntry *entry)
+{
+	Assert(am_leader_apply_worker());
+
+	foreach_ptr(LogicalRepSubUniqueIndex, idxinfo, entry->local_unique_indexes)
+		bms_free(idxinfo->indexkeys);
+
+	list_free_deep(entry->local_unique_indexes);
+	entry->local_unique_indexes = NIL;
+}
+
 /*
  * Free the entry of a relation map cache.
  */
@@ -162,6 +179,9 @@ logicalrep_relmap_free_entry(LogicalRepRelMapEntry *entry)
 
 	if (entry->attrmap)
 		free_attrmap(entry->attrmap);
+
+	if (entry->local_unique_indexes != NIL)
+		free_local_unique_indexes(entry);
 }
 
 /*
@@ -218,6 +238,13 @@ logicalrep_relmap_update(LogicalRepRelation *remoterel)
 		(remoterel->relkind == 0) ? RELKIND_RELATION : remoterel->relkind;
 
 	entry->remoterel.attkeys = bms_copy(remoterel->attkeys);
+
+	/*
+	 * Rebuild the key info using the latest replica identity, which may have
+	 * changed.
+	 */
+	entry->local_unique_indexes_valid = false;
+
 	MemoryContextSwitchTo(oldctx);
 }
 
@@ -404,6 +431,7 @@ logicalrep_rel_open(LogicalRepRelId remoteid, LOCKMODE lockmode)
 			/* Table was renamed or dropped. */
 			entry->localrelvalid = false;
 			entry->parallel_safety_valid = false;
+			entry->local_unique_indexes_valid = false;
 		}
 		else if (!entry->localrelvalid)
 		{
@@ -614,6 +642,157 @@ logicalrep_rel_check_parallel_safety(LogicalRepRelMapEntry *entry)
 	entry->parallel_safety_valid = true;
 }
 
+/*
+ * Collect all local unique indexes that can be used for dependency tracking
+ *
+ * This function collects all types of unique indexes, including those with
+ * index expressions and partial indexes. However, to avoid the overhead and
+ * complexity of executing expressions, we do not evaluate them during
+ * dependency tracking.
+ *
+ * For indexes with expressions, only the non-expression columns are recorded in
+ * the bitmap. The dependency tracking function will use only these columns,
+ * which may lead to false dependency detection. For example, consider a unique
+ * index defined as UNIQUE (a, func(b)), where b is an expression column. Rows
+ * (1, 2) and (1, 3) will be treated as dependent even though they are not. This
+ * is acceptable, as it is still better than disabling parallelism for all
+ * relations that have expression indexes.
+ *
+ * Similarly, partial indexes may also cause false dependencies due to predicate
+ * expressions. For the same reason, we consider this acceptable as well.
+ *
+ * To avoid redundant dependency tracking, indexes whose key columns are the
+ * same as, or a superset of, the replica identity key columns are skipped,
+ * since tracking the replica identity keys already covers their scope.
+ *
+ * Columns not in the replica identity key are excluded from the unique column
+ * set. Since the old tuple of an UPDATE or DELETE contains only replica
+ * identity key columns, any other columns would be missing and thus unavailable
+ * for dependency tracking.
+ */
+void
+logicalrep_build_dependent_unique_indexes(LogicalRepRelMapEntry *entry)
+{
+	List	   *idxlist;
+
+	if (entry->local_unique_indexes_valid)
+		return;
+
+	free_local_unique_indexes(entry);
+
+	/*
+	 * XXX For partitioned tables, we must collect unique indexes from leaf
+	 * partitions, which are the actual replication targets. This is because
+	 * leaf partitions can have unique indexes that are not present on the
+	 * partitioned table, and those indexes can be used for dependency tracking.
+	 * However, collecting unique indexes from leaf partitions requires building
+	 * the tuple, and executing partition pruning expressions, which could be
+	 * expensive for each change on a partitioned table. For now, we skip
+	 * collecting local unique indexes for partitioned tables and create a dummy
+	 * entry, ensuring that changes on partitioned tables are not applied in
+	 * parallel.
+	 */
+	if (entry->localrel->rd_rel->relkind == RELKIND_PARTITIONED_TABLE)
+	{
+		MemoryContext oldctx;
+		LogicalRepSubUniqueIndex *indexinfo;
+
+		oldctx = MemoryContextSwitchTo(LogicalRepRelMapContext);
+		indexinfo = palloc(sizeof(LogicalRepSubUniqueIndex));
+		indexinfo->indexoid = InvalidOid;
+		indexinfo->indexkeys = NULL;
+		indexinfo->nulls_distinct = false;
+		entry->local_unique_indexes = lappend(entry->local_unique_indexes,
+											  indexinfo);
+		MemoryContextSwitchTo(oldctx);
+
+		entry->local_unique_indexes_valid = true;
+
+		return;
+	}
+
+	idxlist = RelationGetIndexList(entry->localrel);
+
+	/* Iterate indexes to list all usable indexes */
+	foreach_oid(idxoid, idxlist)
+	{
+		Relation	idxrel;
+		int			indnkeys;
+		AttrMap    *attrmap;
+		MemoryContext oldctx;
+		LogicalRepSubUniqueIndex *indexinfo;
+		Bitmapset  *indexkeys = NULL;
+		bool		nulls_distinct;
+
+		idxrel = index_open(idxoid, AccessShareLock);
+
+		/* Only unique indexes are considered */
+		if (!idxrel->rd_index->indisunique)
+		{
+			index_close(idxrel, AccessShareLock);
+			continue;
+		}
+
+		indnkeys = idxrel->rd_index->indnkeyatts;
+		nulls_distinct = !idxrel->rd_index->indnullsnotdistinct;
+		attrmap = entry->attrmap;
+
+		Assert(indnkeys);
+
+		/* Seek each attributes and add to a Bitmap */
+		for (int i = 0; i < indnkeys; i++)
+		{
+			AttrNumber	localcol = idxrel->rd_index->indkey.values[i];
+			AttrNumber	remotecol;
+
+			/* Skip expression */
+			if (!AttributeNumberIsValid(localcol))
+				continue;
+
+			remotecol = attrmap->attnums[AttrNumberGetAttrOffset(localcol)];
+
+			/* Skip if the column does not exist on publisher node */
+			if (remotecol < 0)
+				continue;
+
+			/* Skip columns that are not part of the replica identity key */
+			if (!bms_is_member(remotecol, entry->remoterel.attkeys))
+				continue;
+
+			/* Checks are passed, remember the attribute */
+			indexkeys = bms_add_member(indexkeys, remotecol);
+		}
+
+		index_close(idxrel, AccessShareLock);
+
+		/*
+		 * Skip indexes whose key columns are a superset of the replica identity
+		 * key.
+		 */
+		if (bms_equal(entry->remoterel.attkeys, indexkeys) ||
+			bms_is_subset(entry->remoterel.attkeys, indexkeys))
+		{
+			bms_free(indexkeys);
+			continue;
+		}
+
+		oldctx = MemoryContextSwitchTo(LogicalRepRelMapContext);
+		indexinfo = palloc(sizeof(LogicalRepSubUniqueIndex));
+		indexinfo->indexoid = idxoid;
+		indexinfo->indexkeys = bms_copy(indexkeys);
+		indexinfo->nulls_distinct = nulls_distinct;
+		entry->local_unique_indexes = lappend(entry->local_unique_indexes,
+											  indexinfo);
+		MemoryContextSwitchTo(oldctx);
+
+		bms_free(indexkeys);
+	}
+
+	list_free(idxlist);
+
+	entry->local_unique_indexes_valid = true;
+}
+
 /*
  * Partition cache: look up partition LogicalRepRelMapEntry's
  *
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 18d71ceabe6..3864c95f930 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -578,10 +578,20 @@ typedef struct ApplySubXactData
 
 static ApplySubXactData subxact_data = {0, 0, InvalidTransactionId, NULL};
 
+/*
+ * Type of key used for dependency tracking.
+ */
+typedef enum LogicalRepKeyKind
+{
+	LOGICALREP_KEY_REPLICA_IDENTITY,
+	LOGICALREP_KEY_LOCAL_UNIQUE
+} LogicalRepKeyKind;
+
 /* Hash table key for replica_identity_table */
 typedef struct ReplicaIdentityKey
 {
 	Oid			relid;
+	LogicalRepKeyKind kind;
 	LogicalRepTupleData *data;
 } ReplicaIdentityKey;
 
@@ -761,7 +771,8 @@ static bool
 hash_replica_identity_compare(ReplicaIdentityKey *a, ReplicaIdentityKey *b)
 {
 	if (a->relid != b->relid ||
-		a->data->ncols != b->data->ncols)
+		a->data->ncols != b->data->ncols ||
+		a->kind != b->kind)
 		return false;
 
 	for (int i = 0; i < a->data->ncols; i++)
@@ -792,8 +803,12 @@ free_replica_identity_key(ReplicaIdentityKey *key)
 {
 	Assert(key);
 
-	pfree(key->data->colvalues);
-	pfree(key->data->colstatus);
+	if (key->data->colvalues)
+		pfree(key->data->colvalues);
+
+	if (key->data->colstatus)
+		pfree(key->data->colstatus);
+
 	pfree(key->data);
 	pfree(key);
 }
@@ -935,6 +950,79 @@ append_xid_dependency(TransactionId xid, List **depends_on_xids)
 	*depends_on_xids = lappend_xid(*depends_on_xids, xid);
 }
 
+/*
+ * Common function for checking dependency by using the key. Used by both
+ * check_and_record_ri_dependency and check_and_record_local_key_dependency.
+ *
+ * Check whether the given key has an active dependency. If new_depended_xid is
+ * valid, also records a new dependency for that transaction.
+ *
+ * Return the existing transaction ID if an active dependency exists for the
+ * key; otherwise returns InvalidTransactionId.
+ */
+static TransactionId
+check_and_record_key_dependency(ReplicaIdentityKey *key,
+								TransactionId new_depended_xid)
+{
+	TransactionId existing_xid = InvalidTransactionId;
+	ReplicaIdentityEntry *rientry;
+	bool		found = false;
+
+	/*
+	 * The new xid could be invalid if the transaction will be applied by the
+	 * leader itself which means all the changes will be committed before
+	 * processing next transaction. In this case, we only need to check for
+	 * dependencies on preceding transactions, there is no need to record a new
+	 * dependency for subsequent transactions to wait on.
+	 */
+	if (!TransactionIdIsValid(new_depended_xid))
+	{
+		rientry = replica_identity_lookup(replica_identity_table, key);
+
+		if (rientry && has_active_key_dependency(rientry, true))
+		{
+			elog(DEBUG1,
+				 key->kind == LOGICALREP_KEY_REPLICA_IDENTITY ?
+				 "found conflicting replica identity change on table %u from %u" :
+				 "found conflicting local unique key change on table %u from %u",
+				 key->relid, rientry->remote_xid);
+
+			existing_xid = rientry->remote_xid;
+		}
+
+		free_replica_identity_key(key);
+
+		return existing_xid;
+	}
+
+	/* Record a new dependency for subsequent transactions to wait on */
+	rientry = replica_identity_insert(replica_identity_table, key,
+									  &found);
+
+	/*
+	 * Release the key built to search the entry, if the entry already exists.
+	 */
+	if (found)
+	{
+		if (has_active_key_dependency(rientry, false))
+		{
+			elog(DEBUG1,
+				 key->kind == LOGICALREP_KEY_REPLICA_IDENTITY ?
+				 "found conflicting replica identity change on table %u from %u" :
+				 "found conflicting local unique key change on table %u from %u",
+				 key->relid, rientry->remote_xid);
+
+			existing_xid = rientry->remote_xid;
+		}
+
+		free_replica_identity_key(key);
+	}
+
+	rientry->remote_xid = new_depended_xid;
+
+	return existing_xid;
+}
+
 /*
  * Check if any of the key columns have NULL values.
  */
@@ -952,81 +1040,34 @@ has_null_key_values(LogicalRepTupleData *data, Bitmapset *indexkeys)
 }
 
 /*
- * Check for dependencies on preceding transactions that modify the same key as
- * the given tuple. Returns the dependent transactions in 'depends_on_xids'.
- *
- * Additionally, if new_depended_xid is valid, record the current change and the
- * transaction as a new dependency for the replica identity key modification,
- * allowing subsequent transactions that modify the same key to be dependent on
- * it.
+ * Build a hash key for replica_identity_table using the given relation and
+ * tuple data, restricted to the specified key columns.
  */
-static void
-check_and_record_ri_dependency(Oid relid, LogicalRepTupleData *original_data,
-							   TransactionId new_depended_xid,
-							   List **depends_on_xids)
+static ReplicaIdentityKey *
+build_replica_identity_key(Oid relid, LogicalRepTupleData *original_data,
+						   Bitmapset *keycols)
 {
-	LogicalRepRelMapEntry *relentry;
-	LogicalRepTupleData *ridata;
-	ReplicaIdentityKey *rikey;
-	ReplicaIdentityEntry *rientry;
+	LogicalRepTupleData *keydata;
+	ReplicaIdentityKey *key;
 	MemoryContext oldctx;
-	int			n_ri;
-	bool		found = false;
-
-	Assert(depends_on_xids);
+	int		nkeycols = bms_num_members(keycols);
 
-	/* Search for existing entry */
-	relentry = logicalrep_get_relentry(relid);
+	oldctx = MemoryContextSwitchTo(ApplyContext);
 
-	Assert(relentry);
+	/* Allocate space for replica identity values */
+	keydata = palloc0_object(LogicalRepTupleData);
 
-	/*
-	 * First check whether any previous transaction (other than the current one)
-	 * has affected the whole table e.g., truncate or schema change from
-	 * publisher.
-	 */
-	if (has_active_rel_dependency(relentry) &&
-		!TransactionIdEquals(relentry->last_depended_xid, new_depended_xid))
+	if (nkeycols)
 	{
-		elog(DEBUG1, "found table-wide change affecting %u from %u",
-			 relid, relentry->last_depended_xid);
-
-		append_xid_dependency(relentry->last_depended_xid, depends_on_xids);
+		keydata->colvalues = palloc0_array(StringInfoData, nkeycols);
+		keydata->colstatus = palloc0_array(char, nkeycols);
 	}
 
-	n_ri = bms_num_members(relentry->remoterel.attkeys);
-
-	/*
-	 * Return if there are no replica identity columns, indicating that the
-	 * remote relation has neither a replica identity key nor is marked as
-	 * replica identity full.
-	 */
-	if (!n_ri)
-		return;
-
-	/*
-	 * For replica identity FULL, the tuple contains all columns including
-	 * NULLs, so we always check for existing dependencies.
-	 *
-	 * For other cases, NULL in the new tuple means the replica identity key
-	 * hasn't changed, so no new dependency needs to be recorded. The dependency
-	 * should have been recorded when processing the old tuple.
-	 */
-	if (relentry->remoterel.replident != REPLICA_IDENTITY_FULL &&
-		has_null_key_values(original_data, relentry->remoterel.attkeys))
-		return;
-
-	oldctx = MemoryContextSwitchTo(ApplyContext);
+	keydata->ncols = nkeycols;
 
-	/* Allocate space for replica identity values */
-	ridata = palloc0_object(LogicalRepTupleData);
-	ridata->colvalues = palloc0_array(StringInfoData, n_ri);
-	ridata->colstatus = palloc0_array(char, n_ri);
-	ridata->ncols = n_ri;
-
-	for (int i_original = 0, i_ri = 0; i_original < original_data->ncols; i_original++)
+	for (int i = 0, i_key = 0; i < original_data->ncols; i++)
 	{
-		if (!bms_is_member(i_original, relentry->remoterel.attkeys))
+		if (!bms_is_member(i, keycols))
 			continue;
 
 		/*
@@ -1039,77 +1080,206 @@ check_and_record_ri_dependency(Oid relid, LogicalRepTupleData *original_data,
 		 * see the complete replica identity key value in original_data and
 		 * correctly check the dependency.
 		 */
-		Assert(original_data->colstatus[i_original] != LOGICALREP_COLUMN_UNCHANGED ||
-			   original_data->colvalues[i_original].len > 0);
+		Assert(original_data->colstatus[i] != LOGICALREP_COLUMN_UNCHANGED ||
+			   original_data->colvalues[i].len > 0);
 
-		if (original_data->colstatus[i_original] != LOGICALREP_COLUMN_NULL)
+		if (original_data->colstatus[i] != LOGICALREP_COLUMN_NULL)
 		{
-			StringInfo	original_colvalue = &original_data->colvalues[i_original];
+			StringInfo	original_colvalue = &original_data->colvalues[i];
 
-			initStringInfoExt(&ridata->colvalues[i_ri], original_colvalue->len + 1);
-			appendStringInfoString(&ridata->colvalues[i_ri], original_colvalue->data);
+			initStringInfoExt(&keydata->colvalues[i_key], original_colvalue->len + 1);
+			appendStringInfoString(&keydata->colvalues[i_key], original_colvalue->data);
 		}
 
-		ridata->colstatus[i_ri] = original_data->colstatus[i_original];
-		i_ri++;
+		keydata->colstatus[i_key] = original_data->colstatus[i];
+		i_key++;
 	}
 
-	rikey = palloc0_object(ReplicaIdentityKey);
-	rikey->relid = relid;
-	rikey->data = ridata;
+	key = palloc0_object(ReplicaIdentityKey);
+	key->relid = relid;
+	key->data = keydata;
 
 	MemoryContextSwitchTo(oldctx);
 
+	return key;
+}
+
+/*
+ * Build dependency key information for the given relation entry if not already
+ * collected.
+ *
+ * See logicalrep_build_dependent_unique_indexes() for details.
+ */
+static void
+build_local_dependent_key_info(LogicalRepRelMapEntry *relentry)
+{
+	bool		needs_start;
+
+	if (relentry->local_unique_indexes_valid)
+		return;
+
 	/*
-	 * The new xid could be invalid if the transaction will be applied by the
-	 * leader itself which means all the changes will be committed before
-	 * processing next transaction. In this case, we only need to check for
-	 * dependencies on preceding transactions, there is no need to record a new
-	 * dependency for subsequent transactions to wait on.
+	 * Gather information for local indexes if not yet. We require to be in a
+	 * transaction state to collect indexes info from system catalogs.
 	 */
-	if (!TransactionIdIsValid(new_depended_xid))
-	{
-		rientry = replica_identity_lookup(replica_identity_table, rikey);
-		free_replica_identity_key(rikey);
+	needs_start = !IsTransactionState();
 
-		if (rientry && has_active_key_dependency(rientry, true))
-		{
-			elog(DEBUG1, "found conflicting replica identity change on table %u from %u",
-				 relid, rientry->remote_xid);
+	if (needs_start)
+		StartTransactionCommand();
 
-			append_xid_dependency(rientry->remote_xid, depends_on_xids);
-		}
+	relentry = logicalrep_rel_open(relentry->remoterel.remoteid, AccessShareLock);
 
-		return;
-	}
+	logicalrep_build_dependent_unique_indexes(relentry);
 
-	/* Record a new dependency for subsequent transactions to wait on */
-	rientry = replica_identity_insert(replica_identity_table, rikey,
-									  &found);
+	logicalrep_rel_close(relentry, AccessShareLock);
 
-	/*
-	 * Release the key built to search the entry, if the entry already exists.
-	 */
-	if (found)
+	if (needs_start)
+		CommitTransactionCommand();
+}
+
+/*
+ * Mostly same as check_and_record_ri_dependency() but for local unique indexes.
+ *
+ * See the comments in applyparallelworker.c for details on why tracking these
+ * dependencies is necessary.
+ */
+static void
+check_and_record_local_key_dependency(Oid relid,
+									  LogicalRepTupleData *original_data,
+									  bool old_tuple,
+									  TransactionId new_depended_xid,
+									  List **depends_on_xids)
+{
+	LogicalRepRelMapEntry *relentry;
+	ReplicaIdentityKey *rikey;
+
+	Assert(depends_on_xids);
+
+	/* Search for existing entry */
+	relentry = logicalrep_get_relentry(relid);
+
+	Assert(relentry);
+
+	build_local_dependent_key_info(relentry);
+
+	foreach_ptr(LogicalRepSubUniqueIndex, idxinfo, relentry->local_unique_indexes)
 	{
-		free_replica_identity_key(rikey);
+		/*
+		 * NULL values in the new tuple represent true NULLs in a unique index.
+		 * If NULLs are treated as distinct (nulls_distinct = true), they never
+		 * cause conflicts. Therefore, we can skip dependency checking if any
+		 * key column is NULL in this case.
+		 *
+		 * However, for old tuples in UPDATE or DELETE operations, a NULL key
+		 * simply indicate the column lies outside the replica identity key
+		 * rather than a true NULL. In such cases, the remote old tuple could
+		 * still conflict with a local tuple, so we must not skip the check.
+		 */
+		if (!old_tuple && idxinfo->nulls_distinct &&
+			has_null_key_values(original_data, idxinfo->indexkeys))
+			continue;
 
 		/*
-		 * Append the dependency to the list if the current transaction was not
-		 * the lastest one to modify the key.
+		 * Old tuples of unique keys do not conflict with any preceding
+		 * transaction (see the comments in applyparallelworker.c for details on
+		 * conflicting cases). When we don't need to record a new dependency, we
+		 * can skip processing this index entirely.
 		 */
-		if (has_active_key_dependency(rientry, false) &&
-			!TransactionIdEquals(rientry->remote_xid, new_depended_xid))
+		if (old_tuple && !TransactionIdIsValid(new_depended_xid))
+			continue;
+
+		rikey = build_replica_identity_key(relid, original_data, idxinfo->indexkeys);
+		rikey->kind = LOGICALREP_KEY_LOCAL_UNIQUE;
+
+		/*
+		 * For old tuples, record a dependency for subsequent transactions to
+		 * wait on; no preceding transactions are added to the list.
+		 *
+		 * For new tuples in INSERT or UPDATE, check for existing key
+		 * dependencies and add any dependent transactions to the list.
+		 */
+		if (old_tuple)
+		{
+			(void) check_and_record_key_dependency(rikey, new_depended_xid);
+		}
+		else
 		{
-			elog(DEBUG1, "found conflicting replica identity change on table %u from %u",
-				 relid, rientry->remote_xid);
+			TransactionId	xid;
+
+			xid = check_and_record_key_dependency(rikey, InvalidTransactionId);
 
-			append_xid_dependency(rientry->remote_xid, depends_on_xids);
+			if (TransactionIdIsValid(xid) &&
+				!TransactionIdEquals(xid, new_depended_xid))
+				append_xid_dependency(xid, depends_on_xids);
 		}
 	}
+}
 
-	/* Update the new depended xid into the entry */
-	rientry->remote_xid = new_depended_xid;
+/*
+ * Check for dependencies on preceding transactions that modify the same key.
+ * Returns the dependent transactions in 'depends_on_xids'.
+ *
+ * Additionally, if new_depended_xid is valid, record it as a dependency for the
+ * replica identity key modification, allowing subsequent transactions that
+ * modify the same key to be dependent on it.
+ */
+static void
+check_and_record_ri_dependency(Oid relid, LogicalRepTupleData *original_data,
+							   TransactionId new_depended_xid,
+							   List **depends_on_xids)
+{
+	LogicalRepRelMapEntry *relentry;
+	ReplicaIdentityKey *rikey;
+	TransactionId xid;
+
+	Assert(depends_on_xids);
+
+	/* Search for existing entry */
+	relentry = logicalrep_get_relentry(relid);
+
+	Assert(relentry);
+
+	/*
+	 * First check whether any previous transaction (other than the current one)
+	 * has affected the whole table e.g., truncate or schema change from
+	 * publisher.
+	 */
+	if (has_active_rel_dependency(relentry) &&
+		!TransactionIdEquals(relentry->last_depended_xid, new_depended_xid))
+		append_xid_dependency(relentry->last_depended_xid, depends_on_xids);
+
+	/*
+	 * Return if there are no replica identity columns, indicating that the
+	 * remote relation has neither a replica identity key nor is marked as
+	 * replica identity full.
+	 */
+	if (!bms_num_members(relentry->remoterel.attkeys))
+		return;
+
+	/*
+	 * For replica identity FULL, the tuple contains all columns including
+	 * NULLs, so we always check for existing dependencies.
+	 *
+	 * For other cases, NULL in the new tuple means the replica identity key
+	 * hasn't changed, so no new dependency needs to be recorded. The dependency
+	 * should have been recorded when processing the old tuple.
+	 */
+	if (REPLICA_IDENTITY_FULL != relentry->remoterel.replident &&
+		has_null_key_values(original_data, relentry->remoterel.attkeys))
+		return;
+
+	rikey = build_replica_identity_key(relid, original_data, relentry->remoterel.attkeys);
+	rikey->kind = LOGICALREP_KEY_REPLICA_IDENTITY;
+
+	xid = check_and_record_key_dependency(rikey, new_depended_xid);
+
+	/*
+	 * Append the dependency to the list if the current transaction was not the
+	 * lastest one to modify the key.
+	 */
+	if (TransactionIdIsValid(xid) &&
+		!TransactionIdEquals(xid, new_depended_xid))
+		append_xid_dependency(xid, depends_on_xids);
 }
 
 /*
@@ -1308,6 +1478,9 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 			relid = logicalrep_read_insert(&change, &newtup);
 			check_and_record_ri_dependency(relid, &newtup, new_depended_xid,
 										   &depends_on_xids);
+			check_and_record_local_key_dependency(relid, &newtup, false,
+												  new_depended_xid,
+												  &depends_on_xids);
 			break;
 
 		case LOGICAL_REP_MSG_UPDATE:
@@ -1337,12 +1510,29 @@ handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
 
 			check_and_record_ri_dependency(relid, &newtup, new_depended_xid,
 										   &depends_on_xids);
+
+			/*
+			 * Check the new tuple first to detect dependencies on preceding
+			 * transactions that modified the same key. If we processed the old
+			 * tuple first, it might update the same hash entry with the current
+			 * transaction ID, causing the new tuple check to miss any preceding
+			 * transaction.
+			 */
+			check_and_record_local_key_dependency(relid, &newtup, false,
+												  new_depended_xid,
+												  &depends_on_xids);
+			check_and_record_local_key_dependency(relid, &oldtup, true,
+												  new_depended_xid,
+												  &depends_on_xids);
 			break;
 
 		case LOGICAL_REP_MSG_DELETE:
 			relid = logicalrep_read_delete(&change, &oldtup);
 			check_and_record_ri_dependency(relid, &oldtup, new_depended_xid,
 										   &depends_on_xids);
+			check_and_record_local_key_dependency(relid, &oldtup, true,
+												  new_depended_xid,
+												  &depends_on_xids);
 			break;
 
 		case LOGICAL_REP_MSG_TRUNCATE:
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..31bbfcf1971 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -33,7 +33,6 @@
 #include "storage/procnumber.h"
 #include "utils/memutils.h"
 
-
 /*
  * One edge in the waits-for graph.
  *
diff --git a/src/include/replication/logicalrelation.h b/src/include/replication/logicalrelation.h
index 439243be742..ec4c9ece571 100644
--- a/src/include/replication/logicalrelation.h
+++ b/src/include/replication/logicalrelation.h
@@ -56,6 +56,10 @@ typedef struct LogicalRepRelMapEntry
 	 */
 	TransactionId last_depended_xid;
 
+	/* Local unique indexes. Used for dependency tracking */
+	List	   *local_unique_indexes;
+	bool		local_unique_indexes_valid;
+
 	/*
 	 * Per-operation safety cache for parallel apply. If
 	 * parallel_global_unsafe[action] is true, that action cannot be applied in
@@ -74,6 +78,17 @@ typedef struct LogicalRepRelMapEntry
 	bool		parallel_global_unsafe[LRPA_ACTION_COUNT];
 } LogicalRepRelMapEntry;
 
+/*
+ * Subscriber side unique index information.  This is used to track dependencies
+ * between transactions that modify the same unique key value.
+ */
+typedef struct LogicalRepSubUniqueIndex
+{
+	Oid			indexoid;	/* OID of the local key */
+	Bitmapset  *indexkeys;	/* Bitmap of key columns *on remote* */
+	bool		nulls_distinct;	/* Whether NULLs are considered distinct */
+} LogicalRepSubUniqueIndex;
+
 extern void logicalrep_relmap_update(LogicalRepRelation *remoterel);
 extern void logicalrep_partmap_reset_relmap(LogicalRepRelation *remoterel);
 
@@ -84,6 +99,7 @@ extern LogicalRepRelMapEntry *logicalrep_partition_open(LogicalRepRelMapEntry *r
 extern void logicalrep_rel_close(LogicalRepRelMapEntry *rel,
 								 LOCKMODE lockmode);
 extern void logicalrep_rel_check_parallel_safety(LogicalRepRelMapEntry *entry);
+extern void logicalrep_build_dependent_unique_indexes(LogicalRepRelMapEntry *entry);
 extern bool IsIndexUsableForReplicaIdentityFull(Relation idxrel, AttrMap *attrmap);
 extern Oid	GetRelationIdentityOrPK(Relation rel);
 extern int	logicalrep_get_num_rels(void);
diff --git a/src/test/subscription/t/050_parallel_apply.pl b/src/test/subscription/t/050_parallel_apply.pl
index c90a72682e5..9e981bab981 100644
--- a/src/test/subscription/t/050_parallel_apply.pl
+++ b/src/test/subscription/t/050_parallel_apply.pl
@@ -527,4 +527,128 @@ $result =
   $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab");
 is ($result, 2, 'inserts are replicated to subscriber');
 
+##################################################
+# Test that the dependency tracking works correctly for local unique indexes on
+# subscriber during parallel apply.
+##################################################
+
+# Truncate the data for upcoming tests
+$node_publisher->safe_psql('postgres', "TRUNCATE TABLE regress_tab;");
+$node_publisher->wait_for_catchup('regress_sub');
+
+# Define an unique index on subscriber
+$node_subscriber->safe_psql('postgres',
+    "CREATE UNIQUE INDEX local_unique_idx ON regress_tab (value);");
+
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (1, 'would conflict');");
+
+$node_publisher->wait_for_catchup('regress_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab");
+is ($result, 1, 'the insert is replicated to subscriber');
+
+# Attach an injection_point. Parallel workers would wait before the commit
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+# Delete the tuple on publisher.
+$node_publisher->safe_psql('postgres',
+    "DELETE FROM regress_tab WHERE id = 1;");
+
+# Wait until the parallel worker enters the injection point.
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+$offset = -s $node_subscriber->logfile;
+
+# Insert tuples. This should conflict with the DELETE transaction, as both
+# transactions modify the same key. The parallel worker will wait for the
+# preceding transaction to finish.
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (2, 'would conflict');");
+
+# Verify the dependency is detected for the insert
+$str = $node_subscriber->wait_for_log(qr/found conflicting local unique key change on table [1-9][0-9]+ from ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /found conflicting local unique key change on table [1-9][0-9]+ from ([1-9][0-9]+)/;
+
+# Verify the parallel worker waits for the same transaction
+$node_subscriber->wait_for_log(qr/wait for depended xid $xid/, $offset);
+
+ok(1, "local unique key dependency detected for parallel apply");
+
+# Wakeup the parallel worker
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+# Verify the streamed transaction can be applied
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+
+$node_publisher->wait_for_catchup('regress_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab");
+is ($result, 1, 'inserts are replicated to subscriber');
+
+# Test that the dependency tracking works correctly for local unique indexes on
+# subscriber during parallel apply when the unique index has expression.
+$node_subscriber->safe_psql('postgres', "DROP INDEX local_unique_idx;");
+$node_subscriber->safe_psql('postgres',
+    "CREATE UNIQUE INDEX local_unique_idx_expr ON regress_tab ((LOWER(value)));");
+
+# Attach an injection_point. Parallel workers would wait before the commit
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+# Insert a tuple on publisher. Parallel worker would wait at the injection
+# point
+$node_publisher->safe_psql('postgres',
+    "DELETE FROM regress_tab WHERE id = 2;");
+
+# Wait until the parallel worker enters the injection point.
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+$offset = -s $node_subscriber->logfile;
+
+# Insert tuples. This should conflict with the DELETE transaction, as both
+# transactions modify the same key value. The parallel worker will wait for the
+# preceding transaction to finish.
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (3, 'WOULD CONFLICT');");
+
+# Verify the dependency is detected for the insert
+$str = $node_subscriber->wait_for_log(qr/found conflicting local unique key change on table [1-9][0-9]+ from ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /found conflicting local unique key change on table [1-9][0-9]+ from ([1-9][0-9]+)/;
+
+# Verify the parallel worker waits for the same transaction
+$node_subscriber->wait_for_log(qr/wait for depended xid $xid/, $offset);
+
+ok(1, "local unique key dependency from index expression detected for parallel apply");
+
+# Wakeup the parallel worker
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+# Verify the streamed transaction can be applied
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+
+$node_publisher->wait_for_catchup('regress_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab");
+is ($result, 1, 'inserts are replicated to subscriber');
+
+# Cleanup
+$node_subscriber->safe_psql('postgres', "DROP INDEX local_unique_idx_expr;");
+$node_publisher->safe_psql('postgres', "TRUNCATE TABLE regress_tab;");
+$node_publisher->wait_for_catchup('regress_sub');
+
 done_testing();
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index c9b24dbfa59..987489f1ef1 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -1704,6 +1704,7 @@ LogicalRepBeginData
 LogicalRepCommitData
 LogicalRepCommitPreparedTxnData
 LogicalRepCtxStruct
+LogicalRepKeyKind
 LogicalRepMsgType
 LogicalRepPartMapEntry
 LogicalRepPreparedTxnData
@@ -1713,6 +1714,7 @@ LogicalRepRelation
 LogicalRepRollbackPreparedTxnData
 LogicalRepSequenceInfo
 LogicalRepStreamAbortData
+LogicalRepSubscriberIdx
 LogicalRepTupleData
 LogicalRepTyp
 LogicalRepWorker
-- 
2.43.0



  [application/octet-stream] v21-0006-Track-dependencies-for-streamed-transactions.patch (10.4K, ../../TY4PR01MB177180394D8E9C6D263E117E494FE2@TY4PR01MB17718.jpnprd01.prod.outlook.com/6-v21-0006-Track-dependencies-for-streamed-transactions.patch)
  download | inline diff:
From cc527f4bf5b40c34f51f00888bec889d892fd098 Mon Sep 17 00:00:00 2001
From: Zhijie Hou <[email protected]>
Date: Tue, 9 Jun 2026 18:42:48 +0800
Subject: [PATCH v21 6/9] Track dependencies for streamed transactions

This commit allows tracking dependencies of streamed transactions.

Regarding the streaming=on case, dependency tracking is enabled while applying
spooled changes from files.

In the streaming=parallel case, dependency tracking is performed when the leader
sends changes to parallel workers. Apart from non-streamed transactions, the
leader waits for parallel workers till the assigned transactions are finished at
COMMIT/PREPARE/ABORT; thus, the XID of streamed transactions is not cached as
the lastly handled one. Also, streamed transactions are not recorded as
parallelized transactions because upcoming workers do not have to wait for them.
---
 .../replication/logical/applyparallelworker.c |  8 ++-
 src/backend/replication/logical/worker.c      | 62 ++++++++++++++----
 src/include/replication/worker_internal.h     |  1 -
 src/test/subscription/t/050_parallel_apply.pl | 63 +++++++++++++++++++
 4 files changed, 120 insertions(+), 14 deletions(-)

diff --git a/src/backend/replication/logical/applyparallelworker.c b/src/backend/replication/logical/applyparallelworker.c
index 4a7f65345d8..6e478a41e73 100644
--- a/src/backend/replication/logical/applyparallelworker.c
+++ b/src/backend/replication/logical/applyparallelworker.c
@@ -165,7 +165,9 @@
  * key) as another ongoing transaction (see handle_dependency_on_change for
  * details). If so, the leader sends a list of dependent transaction IDs to the
  * parallel worker, indicating that the parallel apply worker must wait for
- * these transactions to commit before proceeding.
+ * these transactions to commit before proceeding. If transactions are streamed
+ * but leader deciedes not to assign parallel apply workers, dependencies are
+ * verified when the transaction is committed.
  *
  * Tracking dependencies is necessary even when commit order is preserved.
  * Consider two transactions: TX-1 (INSERT row 1) and TX-2 (DELETE row 1). If
@@ -1864,6 +1866,10 @@ pa_stream_abort(LogicalRepStreamAbortData *abort_data)
 	TransactionId xid = abort_data->xid;
 	TransactionId subxid = abort_data->subxid;
 
+	/* Streamed transactions won't be registered */
+	Assert(!dshash_find(parallelized_txns, &xid, false) &&
+		   !dshash_find(parallelized_txns, &subxid, false));
+
 	/*
 	 * Update origin state so we can restart streaming from correct position
 	 * in case of crash.
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 1582b7a6c58..0afba345713 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -1558,13 +1558,22 @@ handle_streamed_transaction(LogicalRepMsgType action, StringInfo s)
 		case TRANS_LEADER_SEND_TO_PARALLEL:
 			Assert(winfo);
 
+			/*
+			 * Pass InvalidTransactionId to skip dependency recording for this
+			 * change. Streaming transactions are assumed not to conflict with
+			 * other transactions, so subsequent transactions do not need to
+			 * wait for them to finish.
+			 */
+			handle_dependency_on_change(action, s, InvalidTransactionId, winfo);
+
 			/*
 			 * XXX The publisher side doesn't always send relation/type update
 			 * messages after the streaming transaction, so also update the
 			 * relation/type in leader apply worker. See function
 			 * cleanup_rel_sync_cache.
 			 */
-			if (pa_send_data(winfo, s->len, s->data))
+			if (!winfo->serialize_changes &&
+				pa_send_data(winfo, s->len, s->data))
 				return (action != LOGICAL_REP_MSG_RELATION &&
 						action != LOGICAL_REP_MSG_TYPE);
 
@@ -2695,6 +2704,13 @@ apply_handle_stream_prepare(StringInfo s)
 			apply_spooled_messages(MyLogicalRepWorker->stream_fileset,
 								   prepare_data.xid, prepare_data.prepare_lsn);
 
+			/* Wait until the last transaction finishes */
+			if (TransactionIdIsValid(last_parallelized_remote_xid))
+			{
+				pa_wait_for_depended_transaction(last_parallelized_remote_xid);
+				last_parallelized_remote_xid = InvalidTransactionId;
+			}
+
 			/* Mark the transaction as prepared. */
 			apply_handle_prepare_internal(&prepare_data);
 
@@ -2718,6 +2734,12 @@ apply_handle_stream_prepare(StringInfo s)
 		case TRANS_LEADER_SEND_TO_PARALLEL:
 			Assert(winfo);
 
+			/*
+			 * Build a dependency between this transaction and the lastly
+			 * committed transaction to preserve the commit order.
+			 */
+			build_dependency_with_last_committed_txn(winfo);
+
 			if (pa_send_data(winfo, s->len, s->data))
 			{
 				/* Finish processing the streaming transaction. */
@@ -2784,6 +2806,11 @@ apply_handle_stream_prepare(StringInfo s)
 
 	pgstat_report_stat(false);
 
+	/*
+	 * No need to update the last_parallelized_remote_xid here because leader
+	 * worker always wait until streamed transactions finish.
+	 */
+
 	/*
 	 * Process any tables that are being synchronized in parallel, as well as
 	 * any newly added tables or sequences.
@@ -2897,17 +2924,8 @@ apply_handle_stream_start(StringInfo s)
 
 	set_apply_error_context_xact(stream_xid, InvalidXLogRecPtr);
 
-	/*
-	 * Try to allocate a worker for the streaming transaction.
-	 *
-	 * TODO: Support assigning streaming transactions to parallel apply workers
-	 * even when non-streaming transactions are running and better dependency
-	 * handling between streaming and non-streaming transactions.
-	 */
-	if (first_segment &&
-		am_leader_apply_worker() &&
-		(!TransactionIdIsValid(last_parallelized_remote_xid) ||
-		 pa_transaction_committed(last_parallelized_remote_xid)))
+	/* Try to allocate a worker for the streaming transaction. */
+	if (first_segment)
 		pa_allocate_worker(stream_xid, true);
 
 	apply_action = get_transaction_apply_action(stream_xid, &winfo);
@@ -3571,6 +3589,13 @@ apply_handle_stream_commit(StringInfo s)
 			apply_spooled_messages(MyLogicalRepWorker->stream_fileset, xid,
 								   commit_data.commit_lsn);
 
+			/* Wait until the last transaction finishes */
+			if (TransactionIdIsValid(last_parallelized_remote_xid))
+			{
+				pa_wait_for_depended_transaction(last_parallelized_remote_xid);
+				last_parallelized_remote_xid = InvalidTransactionId;
+			}
+
 			apply_handle_commit_internal(&commit_data);
 
 			/* Unlink the files with serialized changes and subxact info. */
@@ -3582,6 +3607,19 @@ apply_handle_stream_commit(StringInfo s)
 		case TRANS_LEADER_SEND_TO_PARALLEL:
 			Assert(winfo);
 
+			/*
+			 * Apart from non-streaming case, no need to mark this transaction
+			 * as parallelized. Because the leader waits until the streamed
+			 * transaction is committed thus commit ordering is always
+			 * preserved.
+			 */
+
+			/*
+			 * Build a dependency between this transaction and the lastly
+			 * committed transaction to preserve the commit order.
+			 */
+			build_dependency_with_last_committed_txn(winfo);
+
 			if (pa_send_data(winfo, s->len, s->data))
 			{
 				/* Finish processing the streaming transaction. */
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index 02a165ccc0d..3dde899f465 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -370,7 +370,6 @@ extern void pa_switch_to_partial_serialize(ParallelApplyWorkerInfo *winfo,
 extern void pa_set_xact_state(ParallelApplyWorkerShared *wshared,
 							  ParallelTransState xact_state);
 extern void pa_set_stream_apply_worker(ParallelApplyWorkerInfo *winfo);
-
 extern void pa_start_subtrans(TransactionId current_xid,
 							  TransactionId top_xid);
 extern void pa_reset_subtrans(void);
diff --git a/src/test/subscription/t/050_parallel_apply.pl b/src/test/subscription/t/050_parallel_apply.pl
index dc25018b69a..a6343d57bc5 100644
--- a/src/test/subscription/t/050_parallel_apply.pl
+++ b/src/test/subscription/t/050_parallel_apply.pl
@@ -393,4 +393,67 @@ $result =
   $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab");
 is ($result, 20, 'inserts are replicated to subscriber');
 
+##################################################
+# Test that streaming transactions respect commit order preservation when
+# non-streaming transactions are being applied in parallel workers.
+##################################################
+
+$node_publisher->append_conf('postgresql.conf',
+   "logical_decoding_work_mem = 64kB");
+$node_publisher->reload;
+
+# Truncate the data for upcoming tests
+$node_publisher->safe_psql('postgres', "TRUNCATE TABLE regress_tab;");
+$node_publisher->wait_for_catchup('regress_sub');
+
+# Attach the injection_point again
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(71, 80), 'test');");
+
+# Wait until the parallel worker enters the injection point.
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+# Run a transaction which would be streamed
+my $h = $node_publisher->background_psql('postgres', on_error_stop => 0);
+
+$offset = -s $node_subscriber->logfile;
+
+$h->query_safe(
+	q{
+BEGIN;
+UPDATE regress_tab SET value = 'streamed-updated' WHERE id BETWEEN 71 AND 80;
+INSERT INTO regress_tab VALUES (generate_series(100, 5100), 'streamed');
+});
+
+# Verify the dependency is detected for the delete
+$str = $node_subscriber->wait_for_log(qr/found conflicting replica identity change on table [1-9][0-9]+ from ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /found conflicting replica identity change on table [1-9][0-9]+ from ([1-9][0-9]+)/;
+
+# Verify the parallel worker waits for the same transaction
+$node_subscriber->wait_for_log(qr/wait for depended xid $xid/, $offset);
+
+ok(1, "replica identity dependency from streamed txn detected for parallel apply");
+
+# Wakeup the parallel worker
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+# Verify the streamed transaction can be applied
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+
+$h->query_safe("COMMIT;");
+
+$node_publisher->wait_for_catchup('regress_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab");
+is ($result, 5011, 'inserts are replicated to subscriber');
+
 done_testing();
-- 
2.43.0



  [application/octet-stream] v21-0005-support-2PC.patch (13.8K, ../../TY4PR01MB177180394D8E9C6D263E117E494FE2@TY4PR01MB17718.jpnprd01.prod.outlook.com/7-v21-0005-support-2PC.patch)
  download | inline diff:
From 92d467d086d11d8953664317645c51e249aacbcb Mon Sep 17 00:00:00 2001
From: Zhijie Hou <[email protected]>
Date: Wed, 8 Jul 2026 16:07:32 +0800
Subject: [PATCH v21 5/9] support 2PC

This patch allows the PREPARE transaction to be applied in parallel. Parallel
apply workers are assigned to a transaction when BEGIN_PREPARE is received. This
part and the dependency-waiting mechanism are the same as a normal transaction.

A parallel worker can be freed after it handles a PREPARE message. The prepared
transaction can be deleted from parallelized_txns at that time; the upcoming
transactions will wait until then.

The leader apply worker resolves COMMIT PREPARED/ROLLBACK PREPARED. Since it can
be serialized automatically, it does not add the transaction to parallelized_txns.
---
 src/backend/replication/logical/worker.c      | 218 +++++++++++++++---
 src/test/subscription/t/050_parallel_apply.pl |  74 +++++-
 2 files changed, 263 insertions(+), 29 deletions(-)

diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index b5013cbfc00..1582b7a6c58 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -810,6 +810,14 @@ delete_replica_identity_entries_for_txns(List *committed_xids)
 	if (!committed_xids)
 		return;
 
+	/*
+	 * Skip if the replica_identity_table is not initialized yet. This can
+	 * happen if the empty transaction was replicated and a parallel apply
+	 * worker was launched. See comments in apply_handle_prepare().
+	 */
+	if (!replica_identity_table)
+		return;
+
 	replica_identity_start_iterate(replica_identity_table, &i);
 	while ((rientry = replica_identity_iterate(replica_identity_table, &i)) != NULL)
 	{
@@ -2244,6 +2252,8 @@ static void
 apply_handle_begin_prepare(StringInfo s)
 {
 	LogicalRepPreparedTxnData begin_data;
+	ParallelApplyWorkerInfo *winfo;
+	TransApplyAction apply_action;
 
 	/* Tablesync should never receive prepare. */
 	if (am_tablesync_worker())
@@ -2255,12 +2265,54 @@ apply_handle_begin_prepare(StringInfo s)
 	Assert(!TransactionIdIsValid(stream_xid));
 
 	logicalrep_read_begin_prepare(s, &begin_data);
-	set_apply_error_context_xact(begin_data.xid, begin_data.prepare_lsn);
+
+	remote_xid = begin_data.xid;
+
+	set_apply_error_context_xact(remote_xid, begin_data.prepare_lsn);
 
 	remote_final_lsn = begin_data.prepare_lsn;
 
 	maybe_start_skipping_changes(begin_data.prepare_lsn);
 
+	pa_allocate_worker(remote_xid, false);
+
+	apply_action = get_transaction_apply_action(remote_xid, &winfo);
+
+	elog(DEBUG1, "new remote_xid %u", remote_xid);
+	switch (apply_action)
+	{
+		case TRANS_LEADER_APPLY:
+			break;
+
+		case TRANS_LEADER_SEND_TO_PARALLEL:
+			Assert(winfo);
+
+			if (pa_send_data(winfo, s->len, s->data))
+			{
+				pa_set_stream_apply_worker(winfo);
+				break;
+			}
+
+			/*
+			 * TODO: Support switching to PARTIAL_SERIALIZE mode when the send
+			 * buffer becomes full.
+			 */
+			ereport(ERROR,
+					errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+					errmsg("could not send data to the logical replication parallel apply worker"));
+			break;
+
+		case TRANS_PARALLEL_APPLY:
+			/* Hold the lock until the end of the transaction. */
+			pa_lock_transaction(MyParallelShared->xid, AccessExclusiveLock);
+			pa_set_xact_state(MyParallelShared, PARALLEL_TRANS_STARTED);
+			break;
+
+		default:
+			elog(ERROR, "unexpected apply action: %d", (int) apply_action);
+			break;
+	}
+
 	in_remote_transaction = true;
 
 	pgstat_report_activity(STATE_RUNNING, NULL);
@@ -2310,6 +2362,8 @@ static void
 apply_handle_prepare(StringInfo s)
 {
 	LogicalRepPreparedTxnData prepare_data;
+	ParallelApplyWorkerInfo *winfo;
+	TransApplyAction apply_action;
 
 	logicalrep_read_prepare(s, &prepare_data);
 
@@ -2320,36 +2374,131 @@ apply_handle_prepare(StringInfo s)
 								 LSN_FORMAT_ARGS(prepare_data.prepare_lsn),
 								 LSN_FORMAT_ARGS(remote_final_lsn))));
 
-	/*
-	 * Unlike commit, here, we always prepare the transaction even though no
-	 * change has happened in this transaction or all changes are skipped. It
-	 * is done this way because at commit prepared time, we won't know whether
-	 * we have skipped preparing a transaction because of those reasons.
-	 *
-	 * XXX, We can optimize such that at commit prepared time, we first check
-	 * whether we have prepared the transaction or not but that doesn't seem
-	 * worthwhile because such cases shouldn't be common.
-	 */
-	begin_replication_step();
+	apply_action = get_transaction_apply_action(remote_xid, &winfo);
 
-	apply_handle_prepare_internal(&prepare_data);
+	switch (apply_action)
+	{
+		case TRANS_LEADER_APPLY:
 
-	end_replication_step();
-	CommitTransactionCommand();
-	pgstat_report_stat(false);
+			/*
+			 * Unlike commit, here, we always prepare the transaction even
+			 * though no change has happened in this transaction or all
+			 * changes are skipped. It is done this way because at commit
+			 * prepared time, we won't know whether we have skipped preparing
+			 * a transaction because of those reasons.
+			 *
+			 * XXX, We can optimize such that at commit prepared time, we
+			 * first check whether we have prepared the transaction or not but
+			 * that doesn't seem worthwhile because such cases shouldn't be
+			 * common.
+			 */
+			begin_replication_step();
 
-	/*
-	 * It is okay not to set the local_end LSN for the prepare because we
-	 * always flush the prepare record. So, we can send the acknowledgment of
-	 * the remote_end LSN as soon as prepare is finished.
-	 *
-	 * XXX For the sake of consistency with commit, we could have set it with
-	 * the LSN of prepare but as of now we don't track that value similar to
-	 * XactLastCommitEnd, and adding it for this purpose doesn't seems worth
-	 * it.
-	 */
-	store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr,
-						 InvalidTransactionId);
+			/* Wait until the last transaction finishes */
+			if (TransactionIdIsValid(last_parallelized_remote_xid))
+			{
+				pa_wait_for_depended_transaction(last_parallelized_remote_xid);
+				last_parallelized_remote_xid = InvalidTransactionId;
+			}
+
+			apply_handle_prepare_internal(&prepare_data);
+
+			end_replication_step();
+			CommitTransactionCommand();
+			pgstat_report_stat(false);
+
+			/*
+			 * It is okay not to set the local_end LSN for the prepare because
+			 * we always flush the prepare record. So, we can send the
+			 * acknowledgment of the remote_end LSN as soon as prepare is
+			 * finished.
+			 *
+			 * XXX For the sake of consistency with commit, we could have set
+			 * it with the LSN of prepare but as of now we don't track that
+			 * value similar to XactLastCommitEnd, and adding it for this
+			 * purpose doesn't seems worth it.
+			 */
+			store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr,
+								 InvalidTransactionId);
+
+			break;
+
+		case TRANS_LEADER_SEND_TO_PARALLEL:
+			Assert(winfo);
+
+			/*
+			 * Mark this transaction as parallelized. This ensures that
+			 * upcoming transactions wait until this transaction is committed.
+			 */
+			pa_add_parallelized_transaction(remote_xid);
+
+			/*
+			 * Build a dependency between this transaction and the lastly
+			 * committed transaction to preserve the commit order.
+			 */
+			build_dependency_with_last_committed_txn(winfo);
+
+			if (pa_send_data(winfo, s->len, s->data))
+			{
+				/* Cache the remote_xid */
+				last_parallelized_remote_xid = remote_xid;
+
+				/* Finish processing the transaction. */
+				pa_xact_finish(winfo, prepare_data.end_lsn);
+				break;
+			}
+
+			/*
+			 * TODO: Support switching to PARTIAL_SERIALIZE mode when the send
+			 * buffer becomes full.
+			 */
+			ereport(ERROR,
+					errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+					errmsg("could not send data to the logical replication parallel apply worker"));
+			break;
+
+		case TRANS_PARALLEL_APPLY:
+
+			/*
+			 * If the parallel apply worker is applying spooled messages then
+			 * close the file before committing.
+			 */
+			if (stream_fd)
+				stream_close_file();
+
+			begin_replication_step();
+
+			INJECTION_POINT("parallel-worker-before-prepare", NULL);
+
+			/* Mark the transaction as prepared. */
+			apply_handle_prepare_internal(&prepare_data);
+
+			end_replication_step();
+
+			CommitTransactionCommand();
+			pgstat_report_stat(false);
+
+			store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr,
+								 InvalidTransactionId);
+
+			/*
+			 * It is okay not to set the local_end LSN for the prepare because
+			 * we always flush the prepare record. See apply_handle_prepare.
+			 */
+			MyParallelShared->last_commit_end = InvalidXLogRecPtr;
+			pa_commit_transaction();
+
+			pa_unlock_transaction(MyParallelShared->xid, AccessExclusiveLock);
+
+			pa_reset_subtrans();
+			break;
+
+		default:
+			elog(ERROR, "unexpected apply action: %d", (int) apply_action);
+			break;
+	}
+
+	remote_xid = InvalidTransactionId;
 
 	in_remote_transaction = false;
 
@@ -2397,6 +2546,12 @@ apply_handle_commit_prepared(StringInfo s)
 	/* There is no transaction when COMMIT PREPARED is called */
 	begin_replication_step();
 
+	if (TransactionIdIsValid(last_parallelized_remote_xid))
+	{
+		pa_wait_for_depended_transaction(last_parallelized_remote_xid);
+		last_parallelized_remote_xid = InvalidTransactionId;
+	}
+
 	/*
 	 * Update origin state so we can restart streaming from correct position
 	 * in case of crash.
@@ -2465,6 +2620,13 @@ apply_handle_rollback_prepared(StringInfo s)
 
 		/* There is no transaction when ABORT/ROLLBACK PREPARED is called */
 		begin_replication_step();
+
+		if (TransactionIdIsValid(last_parallelized_remote_xid))
+		{
+			pa_wait_for_depended_transaction(last_parallelized_remote_xid);
+			last_parallelized_remote_xid = InvalidTransactionId;
+		}
+
 		FinishPreparedTransaction(gid, false);
 		end_replication_step();
 		CommitTransactionCommand();
diff --git a/src/test/subscription/t/050_parallel_apply.pl b/src/test/subscription/t/050_parallel_apply.pl
index 9d6026470d7..dc25018b69a 100644
--- a/src/test/subscription/t/050_parallel_apply.pl
+++ b/src/test/subscription/t/050_parallel_apply.pl
@@ -19,6 +19,8 @@ if ($ENV{enable_injection_points} ne 'yes')
 # Initialize publisher node
 my $node_publisher = PostgreSQL::Test::Cluster->new('publisher');
 $node_publisher->init(allows_streaming => 'logical');
+$node_publisher->append_conf('postgresql.conf',
+    "max_prepared_transactions = 10");
 $node_publisher->start;
 
 # Create tables and insert initial data
@@ -48,7 +50,8 @@ my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
 $node_subscriber->init;
 $node_subscriber->append_conf('postgresql.conf', "log_min_messages = debug1");
 $node_subscriber->append_conf('postgresql.conf',
-	"max_logical_replication_workers = 10");
+	"max_logical_replication_workers = 10
+    max_prepared_transactions = 10");
 $node_subscriber->start;
 
 # Check if the extension injection_points is available, as it may be
@@ -321,4 +324,73 @@ $result =
   $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab");
 is ($result, 1, 'inserts are replicated to subscriber');
 
+##################################################
+# Test that the prepared transaction can be applied in a parallel apply worker,
+# and that the commit order preservation work correctly.
+##################################################
+
+# Truncate the data for upcoming tests
+$node_publisher->safe_psql('postgres', "TRUNCATE TABLE regress_tab;");
+$node_publisher->wait_for_catchup('regress_sub');
+
+$node_subscriber->safe_psql('postgres',
+    "ALTER SUBSCRIPTION regress_sub DISABLE;");
+$node_subscriber->poll_query_until('postgres',
+	"SELECT count(*) = 0 FROM pg_stat_activity WHERE backend_type = 'logical replication apply worker'"
+);
+$node_subscriber->safe_psql(
+	'postgres', "
+    ALTER SUBSCRIPTION regress_sub SET (two_phase = on);
+    ALTER SUBSCRIPTION regress_sub ENABLE;");
+
+$result = $node_subscriber->safe_psql('postgres',
+    "SELECT count(1) FROM pg_stat_activity WHERE backend_type = 'logical replication parallel worker'");
+is($result, '0', "no parallel apply workers exist after restart");
+
+# Attach an injection_point. Parallel workers would wait before the prepare
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-prepare','wait');"
+);
+
+# PREPARE a transaction on publisher. It would be handled by a parallel apply
+# worker.
+$node_publisher->safe_psql('postgres', qq[
+    BEGIN;
+    INSERT INTO regress_tab VALUES (generate_series(51, 60), 'prepare');
+    PREPARE TRANSACTION 'regress_prepare';
+]);
+
+# Wait until the parallel worker enters the injection point.
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-prepare');
+
+$offset = -s $node_subscriber->logfile;
+
+# Insert tuples on publisher again. This transaction waits for the prepared
+# transaction
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(61, 70), 'test');");
+
+# Verify the parallel worker waits for the transaction
+$str = $node_subscriber->wait_for_log(qr/wait for depended xid ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /wait for depended xid ([1-9][0-9]+)/;
+
+ok(1, "commit order dependency from prepared transaction detected for parallel apply");
+
+# Wakeup the parallel worker
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-prepare');
+    SELECT injection_points_wakeup('parallel-worker-before-prepare');
+]);
+
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+
+# COMMIT the prepared transaction. It is always handled by the leader
+$node_publisher->safe_psql('postgres', "COMMIT PREPARED 'regress_prepare';");
+$node_publisher->wait_for_catchup('regress_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab");
+is ($result, 20, 'inserts are replicated to subscriber');
+
 done_testing();
-- 
2.43.0



  [application/octet-stream] v21-0003-Introduce-a-local-hash-table-to-store-replica-id.patch (35.9K, ../../TY4PR01MB177180394D8E9C6D263E117E494FE2@TY4PR01MB17718.jpnprd01.prod.outlook.com/8-v21-0003-Introduce-a-local-hash-table-to-store-replica-id.patch)
  download | inline diff:
From eaa42b2a5ff7585284e694437f2c2b40eccf35c9 Mon Sep 17 00:00:00 2001
From: Zhijie Hou <[email protected]>
Date: Mon, 15 Jun 2026 18:51:14 +0800
Subject: [PATCH v21 3/9] Introduce a local hash table to store replica
 identities

This local hash table on the leader is used for detecting dependencies between
transactions.

The hash contains the Replica Identity (RI) as a key and the remote XID that
modified the corresponding tuple. The hash entries are inserted when the leader
finds an RI from a replication message, and are removed in the
following cases:

1) When handling a subsequent change that modifies the same row, and the
   stored transaction has already committed.
2) When collecting flush progress of remote transactions, and the stored
   transaction is found to have committed.
3) When the number of entries exceeds REPLICA_IDENTITY_CLEANUP_THRESHOLD
   and the stored transaction has already committed.

When the leader sends replication changes to parallel workers, it checks whether
aother transaction has already used the RI associated with the change. If so,
the leader marks the current transaction as dependent on that transaction and
notifies parallel workers via PA_MSG_XACT_DEPENDENCY to wait for that
transaction to finish.

Author: Zhijie Hou <[email protected]>
Author: Hayato Kuroda <[email protected]>
---
 .../replication/logical/applyparallelworker.c | 131 +++-
 src/backend/replication/logical/relation.c    |  24 +
 src/backend/replication/logical/worker.c      | 739 +++++++++++++++++-
 src/include/replication/logicalrelation.h     |   9 +
 src/include/replication/worker_internal.h     |   6 +-
 src/test/subscription/meson.build             |   1 +
 src/tools/pgindent/typedefs.list              |   3 +
 7 files changed, 908 insertions(+), 5 deletions(-)

diff --git a/src/backend/replication/logical/applyparallelworker.c b/src/backend/replication/logical/applyparallelworker.c
index efde9181a34..30d31e02ac1 100644
--- a/src/backend/replication/logical/applyparallelworker.c
+++ b/src/backend/replication/logical/applyparallelworker.c
@@ -213,17 +213,41 @@
 #define PARALLEL_APPLY_LOCK_XACT	1
 
 /*
- * Hash table entry to map xid to the parallel apply worker state.
+ * Hash table entry of ParallelApplyTxnHash to map xid to the parallel apply
+ * worker state.
  */
 typedef struct ParallelApplyWorkerEntry
 {
-	TransactionId xid;			/* Hash key -- must be first */
+	TransactionId xid;			/* Remote transaction ID (hash key) ---
+								 * must be first */
+
+	/*
+	 * The parallel apply worker assigned for applying the transaction. It is
+	 * NULL if the worker has already finished the transaction and the leader
+	 * has collected its local end LSN.
+	 */
 	ParallelApplyWorkerInfo *winfo;
+
+	/*
+	 * The local end LSN of this transaction applied by the parallel apply
+	 * worker.
+	 *
+	 * The leader will initialize this value by reading the last_commit_end from
+	 * the parallel apply worker shared memory before reusing the worker for
+	 * another transaction (see pa_get_last_commit_end). An invalid value
+	 * indicates the worker has not finished applying the transaction or there
+	 * is no change applied by the worker for this transaction.
+	 */
+	XLogRecPtr	local_end;
 } ParallelApplyWorkerEntry;
 
 /*
  * A hash table used to cache the state of streaming transactions being applied
  * by the parallel apply workers. Entries are of type ParallelApplyWorkerEntry.
+ *
+ * The leader apply worker adds an entry when a transaction starts being
+ * applied, and removes it after collecting the transaction's local end LSN from
+ * the parallel worker.
  */
 static HTAB *ParallelApplyTxnHash = NULL;
 
@@ -237,6 +261,10 @@ typedef struct ParallelizedTxnEntry
  * A hash table used to track the parallelized remote transactions that could be
  * depended on by other transactions. Entries are of type ParallelizedTxnEntry.
  *
+ * The leader apply worker adds an entry before sending the COMMIT record of a
+ * transaction to a parallel apply worker. The entry is removed by that worker
+ * immediately after committing the transaction.
+ *
  * dshash is used to enable dynamic shared memory allocation based on the number
  * of transactions being applied in parallel.
  */
@@ -510,7 +538,7 @@ pa_launch_parallel_worker(void)
  * streaming changes.
  */
 void
-pa_allocate_worker(TransactionId xid)
+pa_allocate_worker(TransactionId xid, bool stream_txn)
 {
 	bool		found;
 	ParallelApplyWorkerInfo *winfo = NULL;
@@ -551,7 +579,9 @@ pa_allocate_worker(TransactionId xid)
 
 	winfo->in_use = true;
 	winfo->serialize_changes = false;
+	winfo->stream_txn = stream_txn;
 	entry->winfo = winfo;
+	entry->local_end = InvalidXLogRecPtr;
 }
 
 /*
@@ -748,6 +778,72 @@ pa_process_spooled_messages_if_required(void)
 	return true;
 }
 
+/*
+ * Get the local end LSN for a transaction applied by a parallel apply worker.
+ *
+ * If the parallel apply worker did not write any changes during the transaction
+ * application due to situations like update/delete_missing or a before trigger,
+ * the *skipped_write will be set to true.
+ *
+ * Once the LSN is retrieved, the transaction entry is removed from
+ * ParallelApplyTxnHash.
+ */
+XLogRecPtr
+pa_get_last_commit_end(TransactionId xid, bool *skipped_write)
+{
+	bool		found;
+	ParallelApplyWorkerEntry *entry;
+	ParallelApplyWorkerInfo *winfo;
+
+	Assert(TransactionIdIsValid(xid));
+	Assert(ParallelApplyTxnHash);
+
+	if (skipped_write)
+		*skipped_write = false;
+
+	/* Find an entry for the requested transaction. */
+	entry = hash_search(ParallelApplyTxnHash, &xid, HASH_FIND, &found);
+
+	if (!found)
+		return InvalidXLogRecPtr;
+
+	/*
+	 * If worker info is NULL, it indicates that the worker has already
+	 * finished handling the transaction. Consequently, the local end LSN has
+	 * already been collected and saved in entry->local_end.
+	 */
+	winfo = entry->winfo;
+	if (winfo == NULL)
+	{
+		if (!hash_search(ParallelApplyTxnHash, &xid, HASH_REMOVE, NULL))
+			elog(ERROR, "hash table corrupted");
+
+		if (skipped_write)
+			*skipped_write = XLogRecPtrIsInvalid(entry->local_end);
+
+		return entry->local_end;
+	}
+
+	/* Return InvalidXLogRecPtr if the transaction is still in progress */
+	if (pa_get_xact_state(winfo->shared) != PARALLEL_TRANS_FINISHED)
+		return InvalidXLogRecPtr;
+
+	/* Collect the local end LSN from the worker's shared memory area */
+	entry->local_end = winfo->shared->last_commit_end;
+	entry->winfo = NULL;
+
+	if (skipped_write)
+		*skipped_write = XLogRecPtrIsInvalid(entry->local_end);
+
+	elog(DEBUG1, "store local commit %X/%X end to txn entry: %u",
+		 LSN_FORMAT_ARGS(entry->local_end), xid);
+
+	if (!hash_search(ParallelApplyTxnHash, &xid, HASH_REMOVE, NULL))
+		elog(ERROR, "hash table corrupted");
+
+	return entry->local_end;
+}
+
 /*
  * Interrupt handler for main loop of parallel apply worker.
  */
@@ -1795,6 +1891,35 @@ pa_xact_finish(ParallelApplyWorkerInfo *winfo, XLogRecPtr remote_lsn)
 	pa_free_worker(winfo);
 }
 
+/*
+ * Check if the given transaction has been committed. This can only be used by
+ * the leader apply worker.
+ *
+ * Returns true if the transaction is committed, false if it is still being
+ * applied in parallel.
+ */
+bool
+pa_transaction_committed(TransactionId xid)
+{
+	bool		found;
+	ParallelApplyWorkerEntry *entry;
+
+	Assert(am_leader_apply_worker());
+	Assert(TransactionIdIsValid(xid));
+	Assert(ParallelApplyTxnHash);
+
+	/* Find an entry for the requested transaction */
+	entry = hash_search(ParallelApplyTxnHash, &xid, HASH_FIND, &found);
+
+	if (!found)
+		return true;
+
+	if (!entry->winfo)
+		return true;
+
+	return pa_get_xact_state(entry->winfo->shared) == PARALLEL_TRANS_FINISHED;
+}
+
 /*
  * Attach to the shared hash table for parallelized transactions.
  */
diff --git a/src/backend/replication/logical/relation.c b/src/backend/replication/logical/relation.c
index 296cbaede30..774e048c263 100644
--- a/src/backend/replication/logical/relation.c
+++ b/src/backend/replication/logical/relation.c
@@ -959,3 +959,27 @@ FindLogicalRepLocalIndex(Relation localrel, LogicalRepRelation *remoterel,
 
 	return InvalidOid;
 }
+
+/*
+ * Get the LogicalRepRelMapEntry corresponding to the given relid without
+ * opening the local relation.
+ */
+LogicalRepRelMapEntry *
+logicalrep_get_relentry(LogicalRepRelId remoteid)
+{
+	LogicalRepRelMapEntry *entry;
+	bool		found;
+
+	if (LogicalRepRelMap == NULL)
+		logicalrep_relmap_init();
+
+	/* Search for existing entry. */
+	entry = hash_search(LogicalRepRelMap, (void *) &remoteid,
+						HASH_FIND, &found);
+
+	if (!found)
+		elog(DEBUG1, "no relation map entry for remote relation ID %u",
+			 remoteid);
+
+	return entry;
+}
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index bd53034cf69..3a1423fbe58 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -303,11 +303,35 @@
 
 #define NAPTIME_PER_CYCLE 1000	/* max sleep time between cycles (1s) */
 
+/*
+ * Struct for tracking the progress of flushing the transaction received from
+ * the publisher.
+ *
+ * A list of these structs (lsn_mapping) is maintained in the apply worker, each
+ * representing a transaction that is being flushed. The entries are removed
+ * from the list when the transaction is fully flushed (see get_flush_position).
+ */
 typedef struct FlushPosition
 {
 	dlist_node	node;
+
+	/*
+	 * The end of commit record applied by the worker, and the corresponding end
+	 * of commit record on the publisher.
+	 *
+	 * For transactions assigned to a parallel apply worker, the local_end is
+	 * not immediately available. The leader will update the local_end when it
+	 * confirms that the parallel apply worker has finished applying the
+	 * transaction (see the usage of pa_get_last_commit_end).
+	 */
 	XLogRecPtr	local_end;
 	XLogRecPtr	remote_end;
+
+	/*
+	 * The remote transaction ID. This should be set to a valid ID only when the
+	 * transaction is assigned to a parallel apply worker.
+	 */
+	TransactionId pa_remote_xid;
 } FlushPosition;
 
 static dlist_head lsn_mapping = DLIST_STATIC_INIT(lsn_mapping);
@@ -476,6 +500,8 @@ ErrorContextCallback *apply_error_context_stack = NULL;
 MemoryContext ApplyMessageContext = NULL;
 MemoryContext ApplyContext = NULL;
 
+static MemoryContext ParallelApplyContext = NULL;
+
 /* per stream context for streaming transactions */
 static MemoryContext LogicalStreamingContext = NULL;
 
@@ -549,6 +575,65 @@ typedef struct ApplySubXactData
 
 static ApplySubXactData subxact_data = {0, 0, InvalidTransactionId, NULL};
 
+/* Hash table key for replica_identity_table */
+typedef struct ReplicaIdentityKey
+{
+	Oid			relid;
+	LogicalRepTupleData *data;
+} ReplicaIdentityKey;
+
+/* Hash table entry in replica_identity_table */
+typedef struct ReplicaIdentityEntry
+{
+	ReplicaIdentityKey *keydata;
+	TransactionId remote_xid;
+
+	/* needed for simplehash */
+	uint32		hash;
+	char		status;
+} ReplicaIdentityEntry;
+
+#include "common/hashfn.h"
+
+static uint32 hash_replica_identity(ReplicaIdentityKey *key);
+static bool hash_replica_identity_compare(ReplicaIdentityKey *a,
+										  ReplicaIdentityKey *b);
+
+/* Define parameters for replica identity hash table code generation. */
+#define SH_PREFIX		replica_identity
+#define SH_ELEMENT_TYPE	ReplicaIdentityEntry
+#define SH_KEY_TYPE		ReplicaIdentityKey *
+#define	SH_KEY			keydata
+#define SH_HASH_KEY(tb, key)	hash_replica_identity(key)
+#define SH_EQUAL(tb, a, b)		hash_replica_identity_compare(a, b)
+#define SH_STORE_HASH
+#define SH_GET_HASH(tb, a) (a)->hash
+#define	SH_SCOPE		static inline
+#define SH_DECLARE
+#define SH_DEFINE
+#include "lib/simplehash.h"
+
+#define REPLICA_IDENTITY_INITIAL_SIZE 128
+#define REPLICA_IDENTITY_CLEANUP_THRESHOLD 1024
+
+/*
+ * Hash table storing replica identity values for changes being applied in
+ * parallel, along with the latest transaction that modified each row.
+ *
+ * Entries are removed in the following cases:
+ *
+ * 1) When handling a subsequent change that modifies the same row, and the
+ *    stored transaction has already committed.
+ * 2) When collecting flush progress of remote transactions, and the stored
+ *    transaction is found to have committed.
+ * 3) When the number of entries exceeds REPLICA_IDENTITY_CLEANUP_THRESHOLD
+ *    and the stored transaction has already committed.
+ *
+ * We use simplehash for its efficiency (see comments atop of simplehash.h), as
+ * this hash table is accessed for every change in a transaction.
+ */
+static replica_identity_hash *replica_identity_table = NULL;
+
 static inline void subxact_filename(char *path, Oid subid, TransactionId xid);
 static inline void changes_filename(char *path, Oid subid, TransactionId xid);
 
@@ -636,6 +721,631 @@ static void set_wal_receiver_timeout(void);
 
 static void on_exit_clear_xact_state(int code, Datum arg);
 
+static void send_internal_dependencies(ParallelApplyWorkerInfo *winfo,
+									   List *depends_on_xids);
+
+/*
+ * Compute the hash value for entries in the replica_identity_table.
+ */
+static uint32
+hash_replica_identity(ReplicaIdentityKey *key)
+{
+	int			i;
+	uint32		hashkey = 0;
+
+	hashkey = hash_combine(hashkey, hash_uint32(key->relid));
+
+	for (i = 0; i < key->data->ncols; i++)
+	{
+		uint32		hkey;
+
+		if (key->data->colstatus[i] == LOGICALREP_COLUMN_NULL)
+			continue;
+
+		hkey = hash_any((const unsigned char *) key->data->colvalues[i].data,
+						key->data->colvalues[i].len);
+		hashkey = hash_combine(hashkey, hkey);
+	}
+
+	return hashkey;
+}
+
+/*
+ * Compare two entries in the replica_identity_table.
+ */
+static bool
+hash_replica_identity_compare(ReplicaIdentityKey *a, ReplicaIdentityKey *b)
+{
+	if (a->relid != b->relid ||
+		a->data->ncols != b->data->ncols)
+		return false;
+
+	for (int i = 0; i < a->data->ncols; i++)
+	{
+		if (a->data->colstatus[i] != b->data->colstatus[i])
+			return false;
+
+		if (a->data->colvalues[i].len != b->data->colvalues[i].len)
+			return false;
+
+		if (strcmp(a->data->colvalues[i].data, b->data->colvalues[i].data))
+			return false;
+
+		elog(DEBUG1, "conflicting key %s", a->data->colvalues[i].data);
+	}
+
+	return true;
+}
+
+/*
+ * Free resources associated with a replica identity key.
+ */
+static void
+free_replica_identity_key(ReplicaIdentityKey *key)
+{
+	Assert(key);
+
+	pfree(key->data->colvalues);
+	pfree(key->data->colstatus);
+	pfree(key->data);
+	pfree(key);
+}
+
+/*
+ * Clean up hash table entries associated with the given transaction IDs.
+ */
+static void
+delete_replica_identity_entries_for_txns(List *committed_xids)
+{
+	replica_identity_iterator i;
+	ReplicaIdentityEntry *rientry;
+
+	if (!committed_xids)
+		return;
+
+	replica_identity_start_iterate(replica_identity_table, &i);
+	while ((rientry = replica_identity_iterate(replica_identity_table, &i)) != NULL)
+	{
+		if (!list_member_xid(committed_xids, rientry->remote_xid))
+			continue;
+
+		/* Clean up the hash entry for committed transaction */
+		free_replica_identity_key(rientry->keydata);
+		replica_identity_delete_item(replica_identity_table, rientry);
+	}
+}
+
+/*
+ * Check committed transactions and clean up corresponding entries in the hash
+ * table.
+ *
+ * Also update the local_end for transactions assigned to parallel apply workers
+ * if not already set.
+ */
+static void
+cleanup_committed_replica_identity_entries(void)
+{
+	dlist_mutable_iter iter;
+	List	   *committed_xids = NIL;
+
+	dlist_foreach_modify(iter, &lsn_mapping)
+	{
+		FlushPosition *pos =
+			dlist_container(FlushPosition, node, iter.cur);
+		bool		skipped_write;
+
+		/*
+		 * Skip if the transaction was not assigned to a parallel worker, or if
+		 * we have already collected its local end LSN.
+		 */
+		if (!TransactionIdIsValid(pos->pa_remote_xid) ||
+			XLogRecPtrIsValid(pos->local_end))
+			continue;
+
+		pos->local_end = pa_get_last_commit_end(pos->pa_remote_xid,
+												&skipped_write);
+
+		elog(DEBUG1,
+			 "got commit end from parallel apply worker, "
+			 "txn: %u, remote_end %X/%X, local_end %X/%X",
+			 pos->pa_remote_xid, LSN_FORMAT_ARGS(pos->remote_end),
+			 LSN_FORMAT_ARGS(pos->local_end));
+
+		/* Skip if the transaction is still being applied */
+		if (!skipped_write && !XLogRecPtrIsValid(pos->local_end))
+			continue;
+
+		committed_xids = lappend_xid(committed_xids, pos->pa_remote_xid);
+	}
+
+	/* cleanup the entries for committed transactions */
+	delete_replica_identity_entries_for_txns(committed_xids);
+}
+
+/*
+ * Check whether the given relation entry contains an uncommitted transaction
+ * that affected the whole table.
+ */
+static bool
+has_active_rel_dependency(LogicalRepRelMapEntry *relentry)
+{
+	if (!TransactionIdIsValid(relentry->last_depended_xid))
+		return false;
+
+	if (pa_transaction_committed(relentry->last_depended_xid))
+	{
+		relentry->last_depended_xid = InvalidTransactionId;
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * Check whether the given key dependency entry contains an uncommitted
+ * transaction. If cleanup is true, also clean up the entry if the transaction
+ * has already committed.
+ */
+static bool
+has_active_key_dependency(ReplicaIdentityEntry *rientry, bool cleanup)
+{
+	Assert(TransactionIdIsValid(rientry->remote_xid));
+
+	if (pa_transaction_committed(rientry->remote_xid))
+	{
+		if (cleanup)
+		{
+			free_replica_identity_key(rientry->keydata);
+			replica_identity_delete_item(replica_identity_table, rientry);
+		}
+
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * Append the given transaction ID to the dependency list if it is not already
+ * present.
+ */
+static void
+append_xid_dependency(TransactionId xid, List **depends_on_xids)
+{
+	Assert(TransactionIdIsValid(xid));
+
+	if (list_member_xid(*depends_on_xids, xid))
+		return;
+
+	*depends_on_xids = lappend_xid(*depends_on_xids, xid);
+}
+
+/*
+ * Check if any of the key columns have NULL values.
+ */
+static bool
+has_null_key_values(LogicalRepTupleData *data, Bitmapset *indexkeys)
+{
+	for (int i = 0; i < data->ncols; i++)
+	{
+		if (bms_is_member(i, indexkeys) &&
+			data->colstatus[i] == LOGICALREP_COLUMN_NULL)
+			return true;
+	}
+
+	return false;
+}
+
+/*
+ * Check for dependencies on preceding transactions that modify the same key as
+ * the given tuple. Returns the dependent transactions in 'depends_on_xids'.
+ *
+ * Additionally, if new_depended_xid is valid, record the current change and the
+ * transaction as a new dependency for the replica identity key modification,
+ * allowing subsequent transactions that modify the same key to be dependent on
+ * it.
+ */
+static void
+check_and_record_ri_dependency(Oid relid, LogicalRepTupleData *original_data,
+							   TransactionId new_depended_xid,
+							   List **depends_on_xids)
+{
+	LogicalRepRelMapEntry *relentry;
+	LogicalRepTupleData *ridata;
+	ReplicaIdentityKey *rikey;
+	ReplicaIdentityEntry *rientry;
+	MemoryContext oldctx;
+	int			n_ri;
+	bool		found = false;
+
+	Assert(depends_on_xids);
+
+	/* Search for existing entry */
+	relentry = logicalrep_get_relentry(relid);
+
+	Assert(relentry);
+
+	/*
+	 * First check whether any previous transaction (other than the current one)
+	 * has affected the whole table e.g., truncate or schema change from
+	 * publisher.
+	 */
+	if (has_active_rel_dependency(relentry) &&
+		!TransactionIdEquals(relentry->last_depended_xid, new_depended_xid))
+	{
+		elog(DEBUG1, "found table-wide change affecting %u from %u",
+			 relid, relentry->last_depended_xid);
+
+		append_xid_dependency(relentry->last_depended_xid, depends_on_xids);
+	}
+
+	n_ri = bms_num_members(relentry->remoterel.attkeys);
+
+	/*
+	 * Return if there are no replica identity columns, indicating that the
+	 * remote relation has neither a replica identity key nor is marked as
+	 * replica identity full.
+	 */
+	if (!n_ri)
+		return;
+
+	/*
+	 * For replica identity FULL, the tuple contains all columns including
+	 * NULLs, so we always check for existing dependencies.
+	 *
+	 * For other cases, NULL in the new tuple means the replica identity key
+	 * hasn't changed, so no new dependency needs to be recorded. The dependency
+	 * should have been recorded when processing the old tuple.
+	 */
+	if (relentry->remoterel.replident != REPLICA_IDENTITY_FULL &&
+		has_null_key_values(original_data, relentry->remoterel.attkeys))
+		return;
+
+	oldctx = MemoryContextSwitchTo(ApplyContext);
+
+	/* Allocate space for replica identity values */
+	ridata = palloc0_object(LogicalRepTupleData);
+	ridata->colvalues = palloc0_array(StringInfoData, n_ri);
+	ridata->colstatus = palloc0_array(char, n_ri);
+	ridata->ncols = n_ri;
+
+	for (int i_original = 0, i_ri = 0; i_original < original_data->ncols; i_original++)
+	{
+		StringInfo	original_colvalue = &original_data->colvalues[i_original];
+
+		if (!bms_is_member(i_original, relentry->remoterel.attkeys))
+			continue;
+
+		/*
+		 * LOGICALREP_COLUMN_UNCHANGED only indicates that a TOAST column in the
+		 * replica identity key hasn't changed. However, other columns may have
+		 * changed, so we still need to check the dependency for this column.
+		 *
+		 * Before calling this function, unchanged TOAST column values should
+		 * have been copied from the old tuple to the new tuple. So, we should
+		 * see the complete replica identity key value in original_data and
+		 * correctly check the dependency.
+		 */
+		Assert(original_data->colstatus[i_original] != LOGICALREP_COLUMN_UNCHANGED ||
+			   original_data->colvalues[i_original].len > 0);
+
+		if (ridata->colstatus[i_ri] != LOGICALREP_COLUMN_NULL)
+		{
+			initStringInfoExt(&ridata->colvalues[i_ri], original_colvalue->len + 1);
+			appendStringInfoString(&ridata->colvalues[i_ri], original_colvalue->data);
+		}
+
+		ridata->colstatus[i_ri] = original_data->colstatus[i_original];
+		i_ri++;
+	}
+
+	rikey = palloc0_object(ReplicaIdentityKey);
+	rikey->relid = relid;
+	rikey->data = ridata;
+
+	MemoryContextSwitchTo(oldctx);
+
+	/*
+	 * The new xid could be invalid if the transaction will be applied by the
+	 * leader itself which means all the changes will be committed before
+	 * processing next transaction. In this case, we only need to check for
+	 * dependencies on preceding transactions, there is no need to record a new
+	 * dependency for subsequent transactions to wait on.
+	 */
+	if (!TransactionIdIsValid(new_depended_xid))
+	{
+		rientry = replica_identity_lookup(replica_identity_table, rikey);
+		free_replica_identity_key(rikey);
+
+		if (rientry && has_active_key_dependency(rientry, true))
+		{
+			elog(DEBUG1, "found conflicting replica identity change on table %u from %u",
+				 relid, rientry->remote_xid);
+
+			append_xid_dependency(rientry->remote_xid, depends_on_xids);
+		}
+
+		return;
+	}
+
+	/* Record a new dependency for subsequent transactions to wait on */
+	rientry = replica_identity_insert(replica_identity_table, rikey,
+									  &found);
+
+	/*
+	 * Release the key built to search the entry, if the entry already exists.
+	 */
+	if (found)
+	{
+		free_replica_identity_key(rikey);
+
+		/*
+		 * Append the dependency to the list if the current transaction was not
+		 * the lastest one to modify the key.
+		 */
+		if (has_active_key_dependency(rientry, false) &&
+			!TransactionIdEquals(rientry->remote_xid, new_depended_xid))
+		{
+			elog(DEBUG1, "found conflicting replica identity change on table %u from %u",
+				 relid, rientry->remote_xid);
+
+			append_xid_dependency(rientry->remote_xid, depends_on_xids);
+		}
+	}
+
+	/* Update the new depended xid into the entry */
+	rientry->remote_xid = new_depended_xid;
+}
+
+/*
+ * Check for preceding transactions (other than 'current_xid') that involve
+ * insert, delete, or update operations on the specified table, and return them
+ * in 'depends_on_xids'.
+ */
+static void
+find_all_dependencies_on_rel(LogicalRepRelId relid,
+							 TransactionId new_depended_xid,
+							 List **depends_on_xids)
+{
+	replica_identity_iterator i;
+	ReplicaIdentityEntry *rientry;
+
+	Assert(depends_on_xids);
+
+	replica_identity_start_iterate(replica_identity_table, &i);
+	while ((rientry = replica_identity_iterate(replica_identity_table, &i)) != NULL)
+	{
+		Assert(TransactionIdIsValid(rientry->remote_xid));
+
+		if (rientry->keydata->relid != relid)
+			continue;
+
+		/* Skip entries that do not have a valid and uncommitted transaction */
+		if (!has_active_key_dependency(rientry, true))
+			continue;
+
+		/* Skip self-dependency */
+		if (TransactionIdEquals(rientry->remote_xid, new_depended_xid))
+			continue;
+
+		elog(DEBUG1, "found conflicting change on table %u from %u",
+			 relid, rientry->remote_xid);
+
+		append_xid_dependency(rientry->remote_xid, depends_on_xids);
+	}
+}
+
+/*
+ * Check for any preceding transactions that affect the given table and returns
+ * them in 'depends_on_xids'.
+ *
+ * Additionally, if new_depended_xid is valid, record the current change and its
+ * transaction as a new table-level dependency, allowing subsequent transactions
+ * that modify the same table to be dependent on it.
+ */
+static void
+check_and_record_rel_dependency(LogicalRepRelId relid,
+								TransactionId new_depended_xid,
+								List **depends_on_xids)
+{
+	LogicalRepRelMapEntry *relentry;
+
+	Assert(depends_on_xids);
+
+	find_all_dependencies_on_rel(relid, new_depended_xid, depends_on_xids);
+
+	/* Search for existing entry */
+	relentry = logicalrep_get_relentry(relid);
+
+	/* First time seeing this relation, no entry exists. */
+	if (!relentry)
+		return;
+
+	/*
+	 * Check whether any previous transaction (other than the current one) has
+	 * affected the whole table e.g., truncate or schema change from publisher.
+	 */
+	if (has_active_rel_dependency(relentry) &&
+		!TransactionIdEquals(relentry->last_depended_xid, new_depended_xid))
+	{
+		elog(DEBUG1, "found table-wide change affecting %u from %u",
+			 relid, relentry->last_depended_xid);
+
+		append_xid_dependency(relentry->last_depended_xid, depends_on_xids);
+	}
+
+	if (TransactionIdIsValid(new_depended_xid))
+		relentry->last_depended_xid = new_depended_xid;
+}
+
+/*
+ * Check dependencies related to the current change by determining if the
+ * modification impacts the same row or table as another ongoing transaction.
+ *
+ * If a dependency on preceding transactions is found, this function instructs
+ * the appropriate worker (parallel apply or leader) to wait for those
+ * transactions to complete.
+ *
+ * Simultaneously, if the current change will be dispatched to a parallel apply
+ * worker (indicated by a valid new_depended_xid and a non-NULL winfo), it
+ * records a new dependency so that subsequent transactions will wait on it.
+ */
+static void
+handle_dependency_on_change(LogicalRepMsgType action, StringInfo s,
+							TransactionId new_depended_xid,
+							ParallelApplyWorkerInfo *winfo)
+{
+	LogicalRepRelId relid;
+	LogicalRepTupleData oldtup;
+	LogicalRepTupleData newtup;
+	LogicalRepRelation *rel;
+	List	   *depends_on_xids = NIL;
+	List	   *remote_relids;
+	bool		has_oldtup = false;
+	bool		cascade = false;
+	bool		restart_seqs = false;
+
+	/*
+	 * Parse the consume data using a local copy instead of directly consuming
+	 * the given remote change as the caller may also read the data from the
+	 * remote message.
+	 */
+	StringInfoData change = *s;
+
+	/* Only the leader checks dependencies and schedules the parallel apply */
+	if (!am_leader_apply_worker())
+		return;
+
+	if (!ParallelApplyContext)
+	{
+		Assert(!replica_identity_table);
+
+		/*
+		 * Create a permanent memory context for dependency information that
+		 * persists across transactions. Using a dedicated context makes memory
+		 * consumption more visible and easier to track, especially when
+		 * handling a large number of change entries for transactions being
+		 * applied in parallel.
+		 */
+		ParallelApplyContext = AllocSetContextCreate(ApplyContext,
+													 "ParallelApplyContext",
+													 ALLOCSET_DEFAULT_SIZES);
+
+		replica_identity_table = replica_identity_create(ParallelApplyContext,
+														 REPLICA_IDENTITY_INITIAL_SIZE,
+														 NULL);
+	}
+
+	if (replica_identity_table->members >= REPLICA_IDENTITY_CLEANUP_THRESHOLD)
+		cleanup_committed_replica_identity_entries();
+
+	switch (action)
+	{
+		case LOGICAL_REP_MSG_INSERT:
+			relid = logicalrep_read_insert(&change, &newtup);
+			check_and_record_ri_dependency(relid, &newtup, new_depended_xid,
+										   &depends_on_xids);
+			break;
+
+		case LOGICAL_REP_MSG_UPDATE:
+			relid = logicalrep_read_update(&change, &has_oldtup, &oldtup,
+										   &newtup);
+
+			if (has_oldtup)
+			{
+				check_and_record_ri_dependency(relid, &oldtup, new_depended_xid,
+											   &depends_on_xids);
+
+				/*
+				 * Copy unchanged column values from the old tuple to the new
+				 * tuple for replica identity dependency checking. See
+				 * check_and_record_ri_dependency() for details. Also adjust the
+				 * column status so that hash comparisons match correctly.
+				 */
+				for (int i = 0; i < oldtup.ncols; i++)
+				{
+					if (newtup.colstatus[i] == LOGICALREP_COLUMN_UNCHANGED)
+					{
+						newtup.colvalues[i] = oldtup.colvalues[i];
+						newtup.colstatus[i] = oldtup.colstatus[i];
+					}
+				}
+			}
+
+			check_and_record_ri_dependency(relid, &newtup, new_depended_xid,
+										   &depends_on_xids);
+			break;
+
+		case LOGICAL_REP_MSG_DELETE:
+			relid = logicalrep_read_delete(&change, &oldtup);
+			check_and_record_ri_dependency(relid, &oldtup, new_depended_xid,
+										   &depends_on_xids);
+			break;
+
+		case LOGICAL_REP_MSG_TRUNCATE:
+			remote_relids = logicalrep_read_truncate(&change, &cascade,
+													 &restart_seqs);
+
+			/*
+			 * Truncate affects all rows in a table, so the current
+			 * transaction should wait for all preceding transactions that
+			 * modified the same table.
+			 */
+			foreach_int(truncated_relid, remote_relids)
+				check_and_record_rel_dependency(truncated_relid,
+												new_depended_xid,
+												&depends_on_xids);
+
+			break;
+
+		case LOGICAL_REP_MSG_RELATION:
+			rel = logicalrep_read_rel(&change);
+
+			/*
+			 * The replica identity key could be changed, making existing
+			 * entries in the replica identity invalid. In this case, parallel
+			 * apply is not allowed on this specific table until all running
+			 * transactions that modified it have finished.
+			 */
+			check_and_record_rel_dependency(rel->remoteid, new_depended_xid,
+											&depends_on_xids);
+			break;
+
+		case LOGICAL_REP_MSG_TYPE:
+		case LOGICAL_REP_MSG_MESSAGE:
+
+			/*
+			 * Type updates accompany relation updates, so dependencies have
+			 * already been checked during relation updates. Logical messages
+			 * do not conflict with any changes, so they can be ignored.
+			 */
+			break;
+
+		default:
+			Assert(false);
+			break;
+	}
+
+	/* Return early if the current change has no dependencies. */
+	if (!depends_on_xids)
+		return;
+
+	/*
+	 * If the leader applies the transaction itself, start waiting for
+	 * transactions that the current change depends on to finish. Otherwise,
+	 * instruct the parallel apply worker to wait for them.
+	 */
+	if (winfo == NULL)
+	{
+		foreach_xid(xid, depends_on_xids)
+			pa_wait_for_depended_transaction(xid);
+	}
+	else
+	{
+		send_internal_dependencies(winfo, depends_on_xids);
+	}
+}
+
 /*
  * Form the origin name for the subscription.
  *
@@ -792,7 +1502,10 @@ handle_streamed_transaction(LogicalRepMsgType action, StringInfo s)
 
 	/* not in streaming mode */
 	if (apply_action == TRANS_LEADER_APPLY)
+	{
+		handle_dependency_on_change(action, s, InvalidTransactionId, winfo);
 		return false;
+	}
 
 	Assert(TransactionIdIsValid(stream_xid));
 
@@ -1244,6 +1957,29 @@ apply_handle_begin(StringInfo s)
 	pgstat_report_activity(STATE_RUNNING, NULL);
 }
 
+/*
+ * Send an INTERNAL_DEPENDENCY message to a parallel apply worker.
+ */
+static void
+send_internal_dependencies(ParallelApplyWorkerInfo *winfo, List *depends_on_xids)
+{
+	StringInfoData dependencies;
+
+	initStringInfo(&dependencies);
+
+	pq_sendbyte(&dependencies, LOGICAL_REP_MSG_INTERNAL_MESSAGE);
+	pq_sendbyte(&dependencies, PA_MSG_XACT_DEPENDENCY);
+	pq_sendint32(&dependencies, list_length(depends_on_xids));
+
+	foreach_xid(xid, depends_on_xids)
+		pq_sendint32(&dependencies, xid);
+
+	if (pa_send_data(winfo, dependencies.len, dependencies.data))
+		ereport(ERROR,
+				errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+				errmsg("could not send data to the logical replication parallel apply worker"));
+}
+
 /*
  * Handle COMMIT message.
  *
@@ -1771,7 +2507,7 @@ apply_handle_stream_start(StringInfo s)
 
 	/* Try to allocate a worker for the streaming transaction. */
 	if (first_segment)
-		pa_allocate_worker(stream_xid);
+		pa_allocate_worker(stream_xid, true);
 
 	apply_action = get_transaction_apply_action(stream_xid, &winfo);
 
@@ -3980,6 +4716,7 @@ store_flush_position(XLogRecPtr remote_lsn, XLogRecPtr local_lsn)
 	flushpos = palloc_object(FlushPosition);
 	flushpos->local_end = local_lsn;
 	flushpos->remote_end = remote_lsn;
+	flushpos->pa_remote_xid = InvalidTransactionId;
 
 	dlist_push_tail(&lsn_mapping, &flushpos->node);
 	MemoryContextSwitchTo(ApplyMessageContext);
diff --git a/src/include/replication/logicalrelation.h b/src/include/replication/logicalrelation.h
index efe0f9d6031..09890cef8d4 100644
--- a/src/include/replication/logicalrelation.h
+++ b/src/include/replication/logicalrelation.h
@@ -37,6 +37,14 @@ typedef struct LogicalRepRelMapEntry
 	/* Sync state. */
 	char		state;
 	XLogRecPtr	statelsn;
+
+	/*
+	 * The last remote transaction that modified the relation's schema or
+	 * truncated the relation. Used in dependency tracking to ensure subsequent
+	 * transactions modifying the same table wait for this transaction to finish
+	 * applying (see check_dependency_on_rel).
+	 */
+	TransactionId last_depended_xid;
 } LogicalRepRelMapEntry;
 
 extern void logicalrep_relmap_update(LogicalRepRelation *remoterel);
@@ -50,5 +58,6 @@ extern void logicalrep_rel_close(LogicalRepRelMapEntry *rel,
 								 LOCKMODE lockmode);
 extern bool IsIndexUsableForReplicaIdentityFull(Relation idxrel, AttrMap *attrmap);
 extern Oid	GetRelationIdentityOrPK(Relation rel);
+extern LogicalRepRelMapEntry *logicalrep_get_relentry(LogicalRepRelId remoteid);
 
 #endif							/* LOGICALRELATION_H */
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index 43dcae43a62..3c88d9e1027 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -239,6 +239,8 @@ typedef struct ParallelApplyWorkerInfo
 	 */
 	bool		in_use;
 
+	bool		stream_txn;
+
 	ParallelApplyWorkerShared *shared;
 } ParallelApplyWorkerInfo;
 
@@ -350,8 +352,9 @@ extern void apply_error_callback(void *arg);
 extern void set_apply_error_context_origin(char *originname);
 
 /* Parallel apply worker setup and interactions */
-extern void pa_allocate_worker(TransactionId xid);
+extern void pa_allocate_worker(TransactionId xid, bool stream_txn);
 extern ParallelApplyWorkerInfo *pa_find_worker(TransactionId xid);
+extern XLogRecPtr pa_get_last_commit_end(TransactionId xid, bool *skipped_write);
 extern void pa_detach_all_error_mq(void);
 
 extern void apply_handle_internal_message(StringInfo s);
@@ -382,6 +385,7 @@ extern void pa_decr_and_wait_stream_block(void);
 
 extern void pa_xact_finish(ParallelApplyWorkerInfo *winfo,
 						   XLogRecPtr remote_lsn);
+extern bool pa_transaction_committed(TransactionId xid);
 
 extern void pa_wait_for_depended_transaction(TransactionId xid);
 
diff --git a/src/test/subscription/meson.build b/src/test/subscription/meson.build
index e71e95c6297..fdc04d91f41 100644
--- a/src/test/subscription/meson.build
+++ b/src/test/subscription/meson.build
@@ -48,6 +48,7 @@ tests += {
       't/036_sequences.pl',
       't/037_except.pl',
       't/038_walsnd_shutdown_timeout.pl',
+      't/039_row_filter_toast_ri.pl',
       't/100_bugs.pl',
     ],
   },
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 0d5959aadb7..9609fd05967 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2671,6 +2671,8 @@ ReplOriginXactState
 ReplaceVarsFromTargetList_context
 ReplaceVarsNoMatchOption
 ReplaceWrapOption
+ReplicaIdentityEntry
+ReplicaIdentityKey
 ReplicaIdentityStmt
 ReplicationKind
 ReplicationSlot
@@ -2682,6 +2684,7 @@ ReplicationSlotPersistentData
 ReplicationState
 ReplicationStateCtl
 ReplicationStateOnDisk
+replica_identity_hash
 ResTarget
 ReservoirState
 ReservoirStateData
-- 
2.43.0



  [application/octet-stream] v21-0004-Parallel-apply-non-streaming-transactions.patch (67.9K, ../../TY4PR01MB177180394D8E9C6D263E117E494FE2@TY4PR01MB17718.jpnprd01.prod.outlook.com/9-v21-0004-Parallel-apply-non-streaming-transactions.patch)
  download | inline diff:
From 82efa9728f925d2bd4c8cb787996940a1bbec91f Mon Sep 17 00:00:00 2001
From: Zhijie Hou <[email protected]>
Date: Mon, 8 Jun 2026 10:16:46 +0800
Subject: [PATCH v21 4/9] Parallel apply non-streaming transactions

--
Basic design
--

The leader worker assigns each non-streaming transaction to a parallel apply
worker. Before dispatching changes to a parallel worker, the leader verifies if
the current modification affects the same row (identitied by replica identity
key) as another ongoing transaction. If so, the leader sends a list of dependent
transaction IDs to the parallel worker, indicating that the parallel apply
worker must wait for these transactions to commit before proceeding.

The leader preserves publisher commit order for all transactions by instructing
the parallel worker to wait for the last transaction to commit before committing
the current transaction. (Note that the leader itself does not wait the
parallelized transaction to be committed unless partial serialization mode is
active, where the parallel apply worker can no longer be reused.) See the
"commit order" and "worker interaction" sections below for details.

Tracking dependencies is necessary even when commit order is preserved.
Consider two transactions: TX-1 (INSERT row 1) and TX-2 (DELETE row 1). If
both are allowed to apply in parallel, TX-2's DELETE could be applied before
TX-1's INSERT, resulting in a delete_missing conflict.

Each parallel apply worker records the local end LSN of the transaction it
applies in shared memory. Subsequently, the leader gathers these local end LSNs
and logs them in the local 'lsn_mapping' for verifying whether they have been
flushed to disk (following the logic in get_flush_position()).

If no parallel apply worker is available, the leader will apply the transaction
independently.

For further details, please refer to the following:

--
dedendency tracking
--

The leader maintains a local hash table, using the remote change's replica
identity column values and relid as keys, with remote transaction IDs as values.
Before sending changes to the parallel apply worker, the leader computes a hash
using RI key values and the relid of the current change to search the hash
table. If an existing entry is found, the leader first updates the hash entry
with the receiving remote xid then tells the parallel worker to wait for it.

If the remote relation lacks a replica identity (RI), it indicates that only
INSERT can be replicated for this table. In such cases, the leader skips
dependency checks, allowing the parallel apply worker to proceed with applying
changes without delay. This is because the only potential conflict could happen
is related to the local unique key or foreign key, which that is yet to be
implemented.

In cases of TRUNCATE or remote schema changes affecting the entire table, the
leader retrieves all remote xids touching the same table (via sequential scans
of the hash table) and tells the parallel worker to wait for those transactions
to commit.

Hash entries are cleaned up once the transaction corresponding to the remote xid
in the entry has been committed. Clean-up typically occurs when collecting the
flush position of each transaction, but is forced if the hash table exceeds a
set threshold.

--
dedendency waiting
--

If a transaction is relied upon by others, the leader adds its xid to a shared
hash table. The shared hash table entry is cleared by the parallel apply worker
upon completing the transaction. Workers needing to wait for a transaction check
the shared hash table entry; if present, they lock the transaction ID (using
pa_lock_transaction). If absent, it indicates the transaction has been
committed, negating the need to wait.

--
commit order
--

We preserve publisher commit order for all transactions for two reasons:

1) User-visible consistency

Out-of-order commits can expose states on the subscriber that were never visible
on the publisher.

For example, suppose a user updates table A and then updates table B on the
publisher. If the subscriber commits those transactions out of order, a query
that sees the latest row in B might still see stale data in A. Although eventual
consistency would still be reached, that behavior may be unacceptable for some
users. In the future, we could provide a GUC to allow out-of-order commits for
users who prefer higher parallelism.

2) Replication progress tracking

We currently track replication progress using transaction commit LSNs. With
out-of-order commits, this becomes ambiguous after failures.

For example, if TX-2 is applied before TX-1 and replication stops due to an
error, we cannot reliably determine whether TX-1 was applied before restart.
As a result, transactions that were already committed on the subscriber may
be replayed.

--
worker interaction
--

After sending the COMMIT message for a transaction, the leader apply worker
does not wait for the parallel apply worker to finish applying that
transaction. Instead, it sends a PA_MSG_XACT_DEPENDENCY message to
the parallel apply worker, instructing it to wait for the last transaction to
commit. This allows the leader to remain busy receiving and dispatching
changes to more parallel apply workers, enabling greater parallelism in
transaction application.

Author: Zhijie Hou <[email protected]>
Author: Hayato Kuroda <[email protected]>
---
 .../replication/logical/applyparallelworker.c | 348 +++++++++++++++--
 src/backend/replication/logical/proto.c       |  38 ++
 src/backend/replication/logical/relation.c    |  31 ++
 src/backend/replication/logical/worker.c      | 367 ++++++++++++++++--
 src/include/replication/logicalproto.h        |   2 +
 src/include/replication/logicalrelation.h     |   2 +
 src/include/replication/worker_internal.h     |  10 +-
 src/test/subscription/meson.build             |   2 +-
 src/test/subscription/t/001_rep_changes.pl    |   2 +
 src/test/subscription/t/010_truncate.pl       |   2 +-
 src/test/subscription/t/015_stream.pl         |   8 +-
 src/test/subscription/t/026_stats.pl          |   1 +
 src/test/subscription/t/027_nosuperuser.pl    |   1 +
 src/test/subscription/t/050_parallel_apply.pl | 324 ++++++++++++++++
 src/tools/pgindent/typedefs.list              |   2 +
 15 files changed, 1058 insertions(+), 82 deletions(-)
 create mode 100644 src/test/subscription/t/050_parallel_apply.pl

diff --git a/src/backend/replication/logical/applyparallelworker.c b/src/backend/replication/logical/applyparallelworker.c
index 30d31e02ac1..4a7f65345d8 100644
--- a/src/backend/replication/logical/applyparallelworker.c
+++ b/src/backend/replication/logical/applyparallelworker.c
@@ -14,6 +14,9 @@
  * ParallelApplyWorkerInfo which is required so the leader worker and parallel
  * apply workers can communicate with each other.
  *
+ * Streaming transactions
+ * ======================
+ *
  * The parallel apply workers are assigned (if available) as soon as xact's
  * first stream is received for subscriptions that have set their 'streaming'
  * option as parallel. The leader apply worker will send changes to this new
@@ -36,12 +39,9 @@
  * its information is added to the ParallelApplyWorkerPool. Once the worker
  * finishes applying the transaction, it is marked as available for re-use.
  * Now, before starting a new worker to apply the streaming transaction, we
- * check the list for any available worker. Note that we retain a maximum of
- * half the max_parallel_apply_workers_per_subscription workers in the pool and
- * after that, we simply exit the worker after applying the transaction.
- *
- * XXX This worker pool threshold is arbitrary and we can provide a GUC
- * variable for this in the future if required.
+ * check the list for any available worker. We do not stop workers in the pool
+ * unless partial of the changes of the transaction are serialized due to a send
+ * timeout.
  *
  * The leader apply worker will create a separate dynamic shared memory segment
  * when each parallel apply worker starts. The reason for this design is that
@@ -152,6 +152,75 @@
  * session-level locks because both locks could be acquired outside the
  * transaction, and the stream lock in the leader needs to persist across
  * transaction boundaries i.e. until the end of the streaming transaction.
+ *
+ * Non-streaming transactions
+ * ======================
+ * The handling is similar to streaming transactions, but including few
+ * differences:
+ *
+ * Transaction dependency
+ * ----------------------
+ * Before dispatching changes to a parallel worker, the leader verifies if the
+ * current modification affects the same row (identitied by replica identity
+ * key) as another ongoing transaction (see handle_dependency_on_change for
+ * details). If so, the leader sends a list of dependent transaction IDs to the
+ * parallel worker, indicating that the parallel apply worker must wait for
+ * these transactions to commit before proceeding.
+ *
+ * Tracking dependencies is necessary even when commit order is preserved.
+ * Consider two transactions: TX-1 (INSERT row 1) and TX-2 (DELETE row 1). If
+ * both are allowed to apply in parallel, TX-2's DELETE could be applied before
+ * TX-1's INSERT, resulting in a delete_missing conflict.
+ *
+ * Commit order
+ * ------------
+ * We preserve publisher commit order for all transactions for two reasons:
+ *
+ * 1) User-visible consistency
+ *
+ * Out-of-order commits can expose states on the subscriber that were never
+ * visible on the publisher.
+ *
+ * For example, suppose a user updates table A and then updates table B on the
+ * publisher. If the subscriber commits those transactions out of order, a
+ * query that sees the latest row in B might still see stale data in A.
+ * Although eventual consistency would still be reached, that behavior may be
+ * unacceptable for some users. In the future, we could provide a subscription
+ * option to allow out-of-order commits for users who prefer higher parallelism.
+ *
+ * 2) Replication progress tracking
+ *
+ * We currently track replication progress using the last transaction's commit
+ * LSNs. With out-of-order commits, this becomes ambiguous after failures.
+ *
+ * For example, if TX-2 is applied before TX-1 and replication stops due to an
+ * error, we cannot reliably determine whether TX-1 was applied before restart.
+ * As a result, transactions that were already committed on the subscriber may
+ * be replayed.
+ *
+ * Worker interaction
+ * ------------
+ * After sending the COMMIT message for a transaction, the leader apply worker
+ * does not wait for the parallel apply worker to finish applying that
+ * transaction. Instead, it sends a PA_MSG_XACT_DEPENDENCY message to
+ * the parallel apply worker, instructing it to wait for the last transaction to
+ * commit. This allows the leader to remain busy receiving and dispatching
+ * changes to more parallel apply workers, enabling greater parallelism in
+ * transaction application.
+ *
+ * Locking considerations
+ * ----------------------
+ * When handling a PA_MSG_RELMAP message, the worker attempts
+ * to acquire the transaction lock of the depended transaction and releases it
+ * immediately after acquisition (see pa_wait_for_depended_transaction). This
+ * allows deadlock detection when one worker (either leader or parallel apply
+ * worker) is waiting for a dependency on a transaction being applied by another
+ * worker, while that other worker is also blocked by a lock held by the first
+ * worker.
+ *
+ * The lock graph for the above example will look as follows: Worker_1 (waiting
+ * for depended transaction to finish) -> Worker_2 (waiting to acquire a
+ * relation lock) -> Worker_1
  *-------------------------------------------------------------------------
  */
 
@@ -316,6 +385,7 @@ static ParallelTransState pa_get_xact_state(ParallelApplyWorkerShared *wshared);
 static PartialFileSetState pa_get_fileset_state(void);
 static void pa_attach_parallelized_txn_hash(dsa_handle *pa_dsa_handle,
 											dshash_table_handle *pa_dshash_handle);
+static void write_internal_relation(StringInfo s, LogicalRepRelation *rel);
 
 /*
  * Returns true if it is OK to start a parallel apply worker, false otherwise.
@@ -433,6 +503,7 @@ pa_setup_dsm(ParallelApplyWorkerInfo *winfo)
 	shared = shm_toc_allocate(toc, sizeof(ParallelApplyWorkerShared));
 	SpinLockInit(&shared->mutex);
 
+	shared->xid = InvalidTransactionId;
 	shared->xact_state = PARALLEL_TRANS_UNKNOWN;
 	pg_atomic_init_u32(&(shared->pending_stream_count), 0);
 	shared->last_commit_end = InvalidXLogRecPtr;
@@ -476,6 +547,8 @@ pa_launch_parallel_worker(void)
 	MemoryContext oldcontext;
 	bool		launched;
 	ParallelApplyWorkerInfo *winfo;
+	dsa_handle	pa_dsa_handle;
+	dshash_table_handle pa_dshash_handle;
 	ListCell   *lc;
 
 	/* Try to get an available parallel apply worker from the worker pool. */
@@ -485,8 +558,36 @@ pa_launch_parallel_worker(void)
 
 		if (!winfo->in_use)
 			return winfo;
+
+		/*
+		 * The leader does not explicitly free workers that handle non-streaming
+		 * transactions, so we check whether the transaction has committed, and
+		 * if so, reuse the worker.
+		 */
+		if (!winfo->stream_txn &&
+			pa_get_xact_state(winfo->shared) == PARALLEL_TRANS_FINISHED)
+		{
+			/*
+			 * Save the local commit LSN of the last transaction applied by
+			 * this worker before reusing it for another transaction. This WAL
+			 * position is crucial for determining the flush position in
+			 * responses to the publisher (see get_flush_position()).
+			 */
+			(void) pa_get_last_commit_end(winfo->shared->xid, false, NULL);
+			return winfo;
+		}
 	}
 
+	pa_attach_parallelized_txn_hash(&pa_dsa_handle, &pa_dshash_handle);
+
+	/*
+	 * Return if the number of parallel apply workers has reached the maximum
+	 * limit.
+	 */
+	if (list_length(ParallelApplyWorkerPool) ==
+		max_parallel_apply_workers_per_subscription)
+		return NULL;
+
 	/*
 	 * Start a new parallel apply worker.
 	 *
@@ -514,18 +615,50 @@ pa_launch_parallel_worker(void)
 										dsm_segment_handle(winfo->dsm_seg),
 										false);
 
-	if (launched)
-	{
-		ParallelApplyWorkerPool = lappend(ParallelApplyWorkerPool, winfo);
-	}
-	else
+	if (!launched)
 	{
+		MemoryContextSwitchTo(oldcontext);
 		pa_free_worker_info(winfo);
-		winfo = NULL;
+		return NULL;
 	}
 
+	ParallelApplyWorkerPool = lappend(ParallelApplyWorkerPool, winfo);
+
 	MemoryContextSwitchTo(oldcontext);
 
+	/*
+	 * Send all existing remote relation information to the parallel apply
+	 * worker. This allows the parallel worker to initialize the
+	 * LogicalRepRelMapEntry locally before applying remote changes. This is
+	 * needed since the walsender does not send remote relation information
+	 * with every transaction.
+	 */
+	if (logicalrep_get_num_rels())
+	{
+		StringInfoData out;
+		shm_mq_result result;
+
+		initStringInfo(&out);
+
+		write_internal_relation(&out, NULL);
+
+		/*
+		 * Send relation information synchronously rather than using
+		 * non-blocking send (pa_send_data). This avoids the need to serialize
+		 * the data, which would add complexity for the parallel apply worker to
+		 * deserialize outside of transaction application.
+		 *
+		 * This is safe from deadlocks because a newly started worker does not
+		 * hold any strong locks when processing relation information.
+		 */
+		result = shm_mq_send(winfo->mq_handle, out.len, out.data, false, true);
+
+		if (result != SHM_MQ_SUCCESS)
+			ereport(ERROR,
+					errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+					errmsg("could not send remote relation information to the logical replication parallel apply worker"));
+	}
+
 	return winfo;
 }
 
@@ -630,25 +763,27 @@ pa_free_worker(ParallelApplyWorkerInfo *winfo)
 {
 	Assert(!am_parallel_apply_worker());
 	Assert(winfo->in_use);
-	Assert(pa_get_xact_state(winfo->shared) == PARALLEL_TRANS_FINISHED);
+	Assert(!winfo->stream_txn ||
+		   pa_get_xact_state(winfo->shared) == PARALLEL_TRANS_FINISHED);
 
 	if (!hash_search(ParallelApplyTxnHash, &winfo->shared->xid, HASH_REMOVE, NULL))
 		elog(ERROR, "hash table corrupted");
 
 	/*
-	 * Stop the worker if there are enough workers in the pool.
+	 * XXX we stop the worker if the leader apply worker serialize part of the
+	 * transaction data due to a send timeout. This is because the message could
+	 * be partially written to the queue and there is no way to clean the queue
+	 * other than resending the message until it succeeds. Instead of trying to
+	 * send the data which anyway would have been serialized and then letting
+	 * the parallel apply worker deal with the spurious message, we stop the
+	 * worker.
 	 *
-	 * XXX Additionally, we also stop the worker if the leader apply worker
-	 * serialize part of the transaction data due to a send timeout. This is
-	 * because the message could be partially written to the queue and there
-	 * is no way to clean the queue other than resending the message until it
-	 * succeeds. Instead of trying to send the data which anyway would have
-	 * been serialized and then letting the parallel apply worker deal with
-	 * the spurious message, we stop the worker.
+	 * For other cases, we do not stop workers once started. All transactions
+	 * (whether streamed or not) are assigned to parallel apply workers, so
+	 * restarting workers frequently would only increase CPU overhead and slow
+	 * down the leader's ability to dispatch changes to workers.
 	 */
-	if (winfo->serialize_changes ||
-		list_length(ParallelApplyWorkerPool) >
-		(max_parallel_apply_workers_per_subscription / 2))
+	if (winfo->serialize_changes)
 	{
 		logicalrep_pa_worker_stop(winfo);
 		pa_free_worker_info(winfo);
@@ -781,15 +916,17 @@ pa_process_spooled_messages_if_required(void)
 /*
  * Get the local end LSN for a transaction applied by a parallel apply worker.
  *
+ * Set delete_entry to true if you intend to remove the transaction from the
+ * ParallelApplyTxnHash after collecting its LSN. Otherwise, the local end LSN
+ * is copied from shared memory into the local entry of ParallelApplyTxnHash for
+ * later collection by the leader.
+ *
  * If the parallel apply worker did not write any changes during the transaction
  * application due to situations like update/delete_missing or a before trigger,
  * the *skipped_write will be set to true.
- *
- * Once the LSN is retrieved, the transaction entry is removed from
- * ParallelApplyTxnHash.
  */
 XLogRecPtr
-pa_get_last_commit_end(TransactionId xid, bool *skipped_write)
+pa_get_last_commit_end(TransactionId xid, bool delete_entry, bool *skipped_write)
 {
 	bool		found;
 	ParallelApplyWorkerEntry *entry;
@@ -815,7 +952,8 @@ pa_get_last_commit_end(TransactionId xid, bool *skipped_write)
 	winfo = entry->winfo;
 	if (winfo == NULL)
 	{
-		if (!hash_search(ParallelApplyTxnHash, &xid, HASH_REMOVE, NULL))
+		if (delete_entry &&
+			!hash_search(ParallelApplyTxnHash, &xid, HASH_REMOVE, NULL))
 			elog(ERROR, "hash table corrupted");
 
 		if (skipped_write)
@@ -838,7 +976,8 @@ pa_get_last_commit_end(TransactionId xid, bool *skipped_write)
 	elog(DEBUG1, "store local commit %X/%X end to txn entry: %u",
 		 LSN_FORMAT_ARGS(entry->local_end), xid);
 
-	if (!hash_search(ParallelApplyTxnHash, &xid, HASH_REMOVE, NULL))
+	if (delete_entry &&
+		!hash_search(ParallelApplyTxnHash, &xid, HASH_REMOVE, NULL))
 		elog(ERROR, "hash table corrupted");
 
 	return entry->local_end;
@@ -1091,6 +1230,9 @@ pa_shutdown(int code, Datum arg)
 				   INVALID_PROC_NUMBER);
 
 	dsm_detach((dsm_segment *) DatumGetPointer(arg));
+
+	if (parallel_apply_dsa_area)
+		dsa_detach(parallel_apply_dsa_area);
 }
 
 /*
@@ -1402,7 +1544,6 @@ pa_send_data(ParallelApplyWorkerInfo *winfo, Size nbytes, const void *data)
 	shm_mq_result result;
 	TimestampTz startTime = 0;
 
-	Assert(!IsTransactionState());
 	Assert(!winfo->serialize_changes);
 
 	/*
@@ -1454,6 +1595,58 @@ pa_send_data(ParallelApplyWorkerInfo *winfo, Size nbytes, const void *data)
 	}
 }
 
+/*
+ * Distribute remote relation information to all active parallel apply workers
+ * that require it.
+ */
+void
+pa_distribute_schema_changes_to_workers(LogicalRepRelation *rel)
+{
+	StringInfoData out;
+
+	/* Only leader apply workers can distribute schema changes */
+	if (!am_leader_apply_worker())
+		return;
+
+	/* Quick exit if there are no parallel apply workers */
+	if (!ParallelApplyWorkerPool)
+		return;
+
+	initStringInfo(&out);
+
+	write_internal_relation(&out, rel);
+
+	foreach_ptr(ParallelApplyWorkerInfo, winfo, ParallelApplyWorkerPool)
+	{
+		/*
+		 * Skip the worker responsible for the current transaction, as the
+		 * relation information has already been sent to it.
+		 */
+		if (winfo == stream_apply_worker)
+			continue;
+
+		/*
+		 * Skip the worker that is in serialize mode, as they will soon stop
+		 * once they finish applying the transaction.
+		 */
+		if (winfo->serialize_changes)
+			continue;
+
+		elog(DEBUG1, "distributing schema changes to pa workers");
+
+		if (pa_send_data(winfo, out.len, out.data))
+			continue;
+
+		/*
+		 * TODO: Support switching to PARTIAL_SERIALIZE mode when the send
+		 * buffer becomes full.
+		 */
+		ereport(ERROR,
+				errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+				errmsg("could not send data to the logical replication parallel apply worker for subscription"));
+	}
+}
+
 /*
  * Switch to PARTIAL_SERIALIZE mode for the current transaction -- this means
  * that the current data and any subsequent data for this transaction will be
@@ -1870,25 +2063,41 @@ pa_decr_and_wait_stream_block(void)
 void
 pa_xact_finish(ParallelApplyWorkerInfo *winfo, XLogRecPtr remote_lsn)
 {
+	XLogRecPtr	local_lsn = InvalidXLogRecPtr;
+	TransactionId pa_remote_xid = winfo->shared->xid;
+
 	Assert(am_leader_apply_worker());
 
 	/*
-	 * Unlock the shared object lock so that parallel apply worker can
-	 * continue to receive and apply changes.
+	 * Unlock the shared object lock taken for streaming transactions so that
+	 * parallel apply worker can continue to receive and apply changes.
 	 */
-	pa_unlock_stream(winfo->shared->xid, AccessExclusiveLock);
+	if (winfo->stream_txn)
+		pa_unlock_stream(winfo->shared->xid, AccessExclusiveLock);
 
 	/*
-	 * Wait for that worker to finish. This is necessary to maintain commit
-	 * order which avoids failures due to transaction dependencies and
-	 * deadlocks.
+	 * Wait for that worker for streaming transaction to finish. This is
+	 * necessary to maintain commit order which avoids failures due to
+	 * transaction dependencies and deadlocks.
+	 *
+	 * For transactions in partial seralize mode, wait for stop as well as the
+	 * worker is anyway cannot be reused anymore (see pa_free_worker() for
+	 * details).
 	 */
-	pa_wait_for_xact_finish(winfo);
+	if (winfo->serialize_changes || winfo->stream_txn)
+	{
+		pa_wait_for_xact_finish(winfo);
+
+		local_lsn = winfo->shared->last_commit_end;
+		pa_remote_xid = InvalidTransactionId;
+
+		pa_free_worker(winfo);
+	}
 
 	if (XLogRecPtrIsValid(remote_lsn))
-		store_flush_position(remote_lsn, winfo->shared->last_commit_end);
+		store_flush_position(remote_lsn, local_lsn, pa_remote_xid);
 
-	pa_free_worker(winfo);
+	pa_set_stream_apply_worker(NULL);
 }
 
 /*
@@ -2022,3 +2231,62 @@ pa_wait_for_depended_transaction(TransactionId xid)
 
 	elog(DEBUG1, "finish waiting for depended xid %u", xid);
 }
+
+/*
+ * Mark the transaction state as finished and remove the shared hash entry.
+ */
+void
+pa_commit_transaction(void)
+{
+	TransactionId xid = MyParallelShared->xid;
+
+	SpinLockAcquire(&MyParallelShared->mutex);
+	MyParallelShared->xact_state = PARALLEL_TRANS_FINISHED;
+	SpinLockRelease(&MyParallelShared->mutex);
+
+	dshash_delete_key(parallelized_txns, &xid);
+	elog(DEBUG1, "xid %u committed", xid);
+}
+
+/*
+ * Write internal relation description to the output stream.
+ */
+static void
+write_internal_relation(StringInfo s, LogicalRepRelation *rel)
+{
+	pq_sendbyte(s, LOGICAL_REP_MSG_INTERNAL_MESSAGE);
+	pq_sendbyte(s, PA_MSG_RELMAP);
+
+	if (rel)
+	{
+		pq_sendint(s, 1, 4);
+		logicalrep_write_internal_rel(s, rel);
+	}
+	else
+	{
+		pq_sendint(s, logicalrep_get_num_rels(), 4);
+		logicalrep_write_all_rels(s);
+	}
+}
+
+/*
+ * Register a transaction to the shared hash table.
+ *
+ * This function is called by the leader during the commit phase of non-streamed
+ * transactions. The parallel apply worker that applies the transaction will
+ * remove it from the hash table upon completion.
+ */
+void
+pa_add_parallelized_transaction(TransactionId xid)
+{
+	bool		found;
+	ParallelizedTxnEntry *txn_entry;
+
+	Assert(parallelized_txns);
+	Assert(TransactionIdIsValid(xid));
+	Assert(am_leader_apply_worker());
+
+	txn_entry = dshash_find_or_insert(parallelized_txns, &xid, &found);
+
+	dshash_release_lock(parallelized_txns, txn_entry);
+}
diff --git a/src/backend/replication/logical/proto.c b/src/backend/replication/logical/proto.c
index a0cac05cdcd..eb1a846ce0c 100644
--- a/src/backend/replication/logical/proto.c
+++ b/src/backend/replication/logical/proto.c
@@ -691,6 +691,44 @@ logicalrep_write_rel(StringInfo out, TransactionId xid, Relation rel,
 	logicalrep_write_attrs(out, rel, columns, include_gencols_type);
 }
 
+/*
+ * Write internal relation description to the output stream.
+ */
+void
+logicalrep_write_internal_rel(StringInfo out, LogicalRepRelation *rel)
+{
+	pq_sendint32(out, rel->remoteid);
+
+	/* Write relation name */
+	pq_sendstring(out, rel->nspname);
+	pq_sendstring(out, rel->relname);
+
+	/* Write the replica identity. */
+	pq_sendbyte(out, rel->replident);
+
+	/* Write attribute description */
+	pq_sendint16(out, rel->natts);
+
+	for (int i = 0; i < rel->natts; i++)
+	{
+		uint8		flags = 0;
+
+		if (bms_is_member(i, rel->attkeys))
+			flags |= LOGICALREP_IS_REPLICA_IDENTITY;
+
+		pq_sendbyte(out, flags);
+
+		/* attribute name */
+		pq_sendstring(out, rel->attnames[i]);
+
+		/* attribute type id */
+		pq_sendint32(out, rel->atttyps[i]);
+
+		/* ignore attribute mode for now */
+		pq_sendint32(out, 0);
+	}
+}
+
 /*
  * Read the relation info from stream and return as LogicalRepRelation.
  */
diff --git a/src/backend/replication/logical/relation.c b/src/backend/replication/logical/relation.c
index 774e048c263..c5a83b095fa 100644
--- a/src/backend/replication/logical/relation.c
+++ b/src/backend/replication/logical/relation.c
@@ -960,6 +960,37 @@ FindLogicalRepLocalIndex(Relation localrel, LogicalRepRelation *remoterel,
 	return InvalidOid;
 }
 
+/*
+ * Get the number of entries in the LogicalRepRelMap.
+ */
+int
+logicalrep_get_num_rels(void)
+{
+	if (LogicalRepRelMap == NULL)
+		return 0;
+
+	return hash_get_num_entries(LogicalRepRelMap);
+}
+
+/*
+ * Write all the remote relation information from the LogicalRepRelMapEntry to
+ * the output stream.
+ */
+void
+logicalrep_write_all_rels(StringInfo out)
+{
+	LogicalRepRelMapEntry *entry;
+	HASH_SEQ_STATUS status;
+
+	if (LogicalRepRelMap == NULL)
+		return;
+
+	hash_seq_init(&status, LogicalRepRelMap);
+
+	while ((entry = (LogicalRepRelMapEntry *) hash_seq_search(&status)) != NULL)
+		logicalrep_write_internal_rel(out, &entry->remoterel);
+}
+
 /*
  * Get the LogicalRepRelMapEntry corresponding to the given relid without
  * opening the local relation.
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 3a1423fbe58..b5013cbfc00 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -290,6 +290,7 @@
 #include "tcop/tcopprot.h"
 #include "utils/acl.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/inval.h"
 #include "utils/lsyscache.h"
 #include "utils/memutils.h"
@@ -514,6 +515,8 @@ static List *on_commit_wakeup_workers_subids = NIL;
 
 bool		in_remote_transaction = false;
 static XLogRecPtr remote_final_lsn = InvalidXLogRecPtr;
+static TransactionId remote_xid = InvalidTransactionId;
+static TransactionId last_parallelized_remote_xid = InvalidTransactionId;
 
 /* fields valid only when processing streamed transaction */
 static bool in_streamed_transaction = false;
@@ -723,6 +726,7 @@ static void on_exit_clear_xact_state(int code, Datum arg);
 
 static void send_internal_dependencies(ParallelApplyWorkerInfo *winfo,
 									   List *depends_on_xids);
+static void build_dependency_with_last_committed_txn(ParallelApplyWorkerInfo *winfo);
 
 /*
  * Compute the hash value for entries in the replica_identity_table.
@@ -765,6 +769,9 @@ hash_replica_identity_compare(ReplicaIdentityKey *a, ReplicaIdentityKey *b)
 		if (a->data->colstatus[i] != b->data->colstatus[i])
 			return false;
 
+		if (a->data->colstatus[i] == LOGICALREP_COLUMN_NULL)
+			continue;
+
 		if (a->data->colvalues[i].len != b->data->colvalues[i].len)
 			return false;
 
@@ -842,7 +849,7 @@ cleanup_committed_replica_identity_entries(void)
 			XLogRecPtrIsValid(pos->local_end))
 			continue;
 
-		pos->local_end = pa_get_last_commit_end(pos->pa_remote_xid,
+		pos->local_end = pa_get_last_commit_end(pos->pa_remote_xid, false,
 												&skipped_write);
 
 		elog(DEBUG1,
@@ -1011,8 +1018,6 @@ check_and_record_ri_dependency(Oid relid, LogicalRepTupleData *original_data,
 
 	for (int i_original = 0, i_ri = 0; i_original < original_data->ncols; i_original++)
 	{
-		StringInfo	original_colvalue = &original_data->colvalues[i_original];
-
 		if (!bms_is_member(i_original, relentry->remoterel.attkeys))
 			continue;
 
@@ -1029,8 +1034,10 @@ check_and_record_ri_dependency(Oid relid, LogicalRepTupleData *original_data,
 		Assert(original_data->colstatus[i_original] != LOGICALREP_COLUMN_UNCHANGED ||
 			   original_data->colvalues[i_original].len > 0);
 
-		if (ridata->colstatus[i_ri] != LOGICALREP_COLUMN_NULL)
+		if (original_data->colstatus[i_original] != LOGICALREP_COLUMN_NULL)
 		{
+			StringInfo	original_colvalue = &original_data->colvalues[i_original];
+
 			initStringInfoExt(&ridata->colvalues[i_ri], original_colvalue->len + 1);
 			appendStringInfoString(&ridata->colvalues[i_ri], original_colvalue->data);
 		}
@@ -1498,14 +1505,14 @@ handle_streamed_transaction(LogicalRepMsgType action, StringInfo s)
 	TransApplyAction apply_action;
 	StringInfoData original_msg;
 
+	if (!in_streamed_transaction)
+		return false;
+
 	apply_action = get_transaction_apply_action(stream_xid, &winfo);
 
 	/* not in streaming mode */
 	if (apply_action == TRANS_LEADER_APPLY)
-	{
-		handle_dependency_on_change(action, s, InvalidTransactionId, winfo);
 		return false;
-	}
 
 	Assert(TransactionIdIsValid(stream_xid));
 
@@ -1580,6 +1587,73 @@ handle_streamed_transaction(LogicalRepMsgType action, StringInfo s)
 	}
 }
 
+/*
+ * Handle non-streaming transactions when parallel apply is in use.
+ *
+ * This function runs only in the leader apply worker while processing a remote
+ * transaction. It checks whether the current change has dependencies on
+ * preceding parallelized transactions and decides whether to send the change to
+ * a parallel apply worker.
+ *
+ * Returns true if the change has been dispatched to a parallel worker,
+ * indicating the leader does not need to apply it directly. Returns false
+ * otherwise.
+ *
+ * Exception: If the message being processed is LOGICAL_REP_MSG_RELATION or
+ * LOGICAL_REP_MSG_TYPE, return false even if the message needs to be sent to a
+ * parallel apply worker.
+ */
+static bool
+handle_parallelized_transaction(LogicalRepMsgType action, StringInfo s)
+{
+	ParallelApplyWorkerInfo *winfo;
+	TransApplyAction apply_action;
+
+	/*
+	 * Dependency checking for non-streaming transactions is only required in
+	 * the leader apply worker during a remote transaction.
+	 */
+	if (!in_remote_transaction || !am_leader_apply_worker())
+		return false;
+
+	apply_action = get_transaction_apply_action(remote_xid, &winfo);
+
+	/* not assigned to parallel apply worker, apply in leader */
+	if (apply_action == TRANS_LEADER_APPLY)
+	{
+		handle_dependency_on_change(action, s, InvalidTransactionId, winfo);
+		return false;
+	}
+
+	Assert(TransactionIdIsValid(remote_xid));
+
+	handle_dependency_on_change(action, s, remote_xid, winfo);
+
+	switch (apply_action)
+	{
+		case TRANS_LEADER_SEND_TO_PARALLEL:
+			Assert(winfo);
+
+			/* Always update relation and type cache in leader apply worker */
+			if (pa_send_data(winfo, s->len, s->data))
+				return (action != LOGICAL_REP_MSG_RELATION &&
+						action != LOGICAL_REP_MSG_TYPE);
+
+			/*
+			 * TODO: Support switching to PARTIAL_SERIALIZE mode when the send
+			 * buffer becomes full.
+			 */
+			ereport(ERROR,
+					errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+					errmsg("could not send data to the logical replication parallel apply worker"));
+			return false;	/* silence compiler warning */
+
+		default:
+			elog(ERROR, "unexpected apply action: %d", (int) apply_action);
+			return false;		/* silence compiler warning */
+	}
+}
+
 /*
  * Executor state preparation for evaluation of constraint expressions,
  * indexes and triggers for the specified relation.
@@ -1941,17 +2015,61 @@ static void
 apply_handle_begin(StringInfo s)
 {
 	LogicalRepBeginData begin_data;
+	ParallelApplyWorkerInfo *winfo;
+	TransApplyAction apply_action;
 
 	/* There must not be an active streaming transaction. */
 	Assert(!TransactionIdIsValid(stream_xid));
 
 	logicalrep_read_begin(s, &begin_data);
-	set_apply_error_context_xact(begin_data.xid, begin_data.final_lsn);
+
+	remote_xid = begin_data.xid;
+
+	set_apply_error_context_xact(remote_xid, begin_data.final_lsn);
 
 	remote_final_lsn = begin_data.final_lsn;
 
 	maybe_start_skipping_changes(begin_data.final_lsn);
 
+	pa_allocate_worker(remote_xid, false);
+
+	apply_action = get_transaction_apply_action(remote_xid, &winfo);
+
+	elog(DEBUG1, "new remote_xid %u", remote_xid);
+	switch (apply_action)
+	{
+		case TRANS_LEADER_APPLY:
+			break;
+
+		case TRANS_LEADER_SEND_TO_PARALLEL:
+			Assert(winfo);
+
+			if (pa_send_data(winfo, s->len, s->data))
+			{
+				pa_set_stream_apply_worker(winfo);
+				break;
+			}
+
+			/*
+			 * TODO: Support switching to PARTIAL_SERIALIZE mode when the send
+			 * buffer becomes full.
+			 */
+			ereport(ERROR,
+					errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+					errmsg("could not send data to the logical replication parallel apply worker"));
+			break;
+
+		case TRANS_PARALLEL_APPLY:
+			/* Hold the lock until the end of the transaction. */
+			pa_lock_transaction(MyParallelShared->xid, AccessExclusiveLock);
+			pa_set_xact_state(MyParallelShared, PARALLEL_TRANS_STARTED);
+			break;
+
+		default:
+			elog(ERROR, "unexpected apply action: %d", (int) apply_action);
+			break;
+	}
+
 	in_remote_transaction = true;
 
 	pgstat_report_activity(STATE_RUNNING, NULL);
@@ -1974,12 +2092,33 @@ send_internal_dependencies(ParallelApplyWorkerInfo *winfo, List *depends_on_xids
 	foreach_xid(xid, depends_on_xids)
 		pq_sendint32(&dependencies, xid);
 
-	if (pa_send_data(winfo, dependencies.len, dependencies.data))
+	/*
+	 * TODO: Support switching to PARTIAL_SERIALIZE mode when the send
+	 * buffer becomes full.
+	 */
+	if (!pa_send_data(winfo, dependencies.len, dependencies.data))
 		ereport(ERROR,
 				errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
 				errmsg("could not send data to the logical replication parallel apply worker"));
 }
 
+/*
+ * Make a dependency between this and the lastly committed transaction.
+ *
+ * Sends an INTERNAL_DEPENDENCY message to the parallel apply worker,
+ * instructing it to wait for the last committed transaction to finish before
+ * committing its own, thereby preserving commit order.
+ */
+static void
+build_dependency_with_last_committed_txn(ParallelApplyWorkerInfo *winfo)
+{
+	/* Skip if transactions have not been applied yet */
+	if (!TransactionIdIsValid(last_parallelized_remote_xid))
+		return;
+
+	send_internal_dependencies(winfo, list_make1_xid(last_parallelized_remote_xid));
+}
+
 /*
  * Handle COMMIT message.
  *
@@ -1989,6 +2128,8 @@ static void
 apply_handle_commit(StringInfo s)
 {
 	LogicalRepCommitData commit_data;
+	ParallelApplyWorkerInfo *winfo;
+	TransApplyAction apply_action;
 
 	logicalrep_read_commit(s, &commit_data);
 
@@ -1999,7 +2140,92 @@ apply_handle_commit(StringInfo s)
 								 LSN_FORMAT_ARGS(commit_data.commit_lsn),
 								 LSN_FORMAT_ARGS(remote_final_lsn))));
 
-	apply_handle_commit_internal(&commit_data);
+	apply_action = get_transaction_apply_action(remote_xid, &winfo);
+
+	switch (apply_action)
+	{
+		case TRANS_LEADER_APPLY:
+
+			/*
+			 * Apart from parallelized transactions, we do not have to
+			 * register this transaction to parallelized_txns. The commit
+			 * ordering is always preserved.
+			 */
+
+			/* Wait until the last transaction finishes */
+			if (TransactionIdIsValid(last_parallelized_remote_xid))
+			{
+				pa_wait_for_depended_transaction(last_parallelized_remote_xid);
+				last_parallelized_remote_xid = InvalidTransactionId;
+			}
+
+			apply_handle_commit_internal(&commit_data);
+
+			break;
+
+		case TRANS_LEADER_SEND_TO_PARALLEL:
+			Assert(winfo);
+
+			/*
+			 * Mark this transaction as parallelized. This ensures that
+			 * upcoming transactions wait until this transaction is committed.
+			 */
+			pa_add_parallelized_transaction(remote_xid);
+
+			/*
+			 * Build a dependency between this transaction and the lastly
+			 * committed transaction to preserve the commit order.
+			 */
+			build_dependency_with_last_committed_txn(winfo);
+
+			if (pa_send_data(winfo, s->len, s->data))
+			{
+				/* Cache the remote_xid */
+				last_parallelized_remote_xid = remote_xid;
+
+				/* Finish processing the transaction. */
+				pa_xact_finish(winfo, commit_data.end_lsn);
+				break;
+			}
+
+			/*
+			 * TODO: Support switching to PARTIAL_SERIALIZE mode when the send
+			 * buffer becomes full.
+			 */
+			ereport(ERROR,
+					errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+					errmsg("could not send data to the logical replication parallel apply worker"));
+			break;
+
+		case TRANS_PARALLEL_APPLY:
+
+			/*
+			 * If the parallel apply worker is applying spooled messages then
+			 * close the file before committing.
+			 */
+			if (stream_fd)
+				stream_close_file();
+
+			INJECTION_POINT("parallel-worker-before-commit", NULL);
+
+			apply_handle_commit_internal(&commit_data);
+
+			MyParallelShared->last_commit_end = XactLastCommitEnd;
+
+			pa_commit_transaction();
+
+			pa_unlock_transaction(remote_xid, AccessExclusiveLock);
+			break;
+
+		default:
+			elog(ERROR, "unexpected apply action: %d", (int) apply_action);
+			break;
+	}
+
+	remote_xid = InvalidTransactionId;
+	in_remote_transaction = false;
+
+	elog(DEBUG1, "reset remote_xid %u", remote_xid);
 
 	/*
 	 * Process any tables that are being synchronized in parallel, as well as
@@ -2122,7 +2348,8 @@ apply_handle_prepare(StringInfo s)
 	 * XactLastCommitEnd, and adding it for this purpose doesn't seems worth
 	 * it.
 	 */
-	store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr);
+	store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr,
+						 InvalidTransactionId);
 
 	in_remote_transaction = false;
 
@@ -2182,7 +2409,8 @@ apply_handle_commit_prepared(StringInfo s)
 	CommitTransactionCommand();
 	pgstat_report_stat(false);
 
-	store_flush_position(prepare_data.end_lsn, XactLastCommitEnd);
+	store_flush_position(prepare_data.end_lsn, XactLastCommitEnd,
+						 InvalidTransactionId);
 	in_remote_transaction = false;
 
 	/*
@@ -2251,7 +2479,8 @@ apply_handle_rollback_prepared(StringInfo s)
 	 * transaction because we always flush the WAL record for it. See
 	 * apply_handle_prepare.
 	 */
-	store_flush_position(rollback_data.rollback_end_lsn, InvalidXLogRecPtr);
+	store_flush_position(rollback_data.rollback_end_lsn, InvalidXLogRecPtr,
+						 InvalidTransactionId);
 	in_remote_transaction = false;
 
 	/*
@@ -2313,7 +2542,8 @@ apply_handle_stream_prepare(StringInfo s)
 			 * It is okay not to set the local_end LSN for the prepare because
 			 * we always flush the prepare record. See apply_handle_prepare.
 			 */
-			store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr);
+			store_flush_position(prepare_data.end_lsn, InvalidXLogRecPtr,
+								 InvalidTransactionId);
 
 			in_remote_transaction = false;
 
@@ -2505,8 +2735,17 @@ apply_handle_stream_start(StringInfo s)
 
 	set_apply_error_context_xact(stream_xid, InvalidXLogRecPtr);
 
-	/* Try to allocate a worker for the streaming transaction. */
-	if (first_segment)
+	/*
+	 * Try to allocate a worker for the streaming transaction.
+	 *
+	 * TODO: Support assigning streaming transactions to parallel apply workers
+	 * even when non-streaming transactions are running and better dependency
+	 * handling between streaming and non-streaming transactions.
+	 */
+	if (first_segment &&
+		am_leader_apply_worker() &&
+		(!TransactionIdIsValid(last_parallelized_remote_xid) ||
+		 pa_transaction_committed(last_parallelized_remote_xid)))
 		pa_allocate_worker(stream_xid, true);
 
 	apply_action = get_transaction_apply_action(stream_xid, &winfo);
@@ -3021,8 +3260,7 @@ apply_spooled_messages(FileSet *stream_fileset, TransactionId xid,
 	int			fileno;
 	pgoff_t		offset;
 
-	if (!am_parallel_apply_worker())
-		maybe_start_skipping_changes(lsn);
+	maybe_start_skipping_changes(lsn);
 
 	/* Make sure we have an open transaction */
 	begin_replication_step();
@@ -3292,7 +3530,8 @@ apply_handle_commit_internal(LogicalRepCommitData *commit_data)
 
 		pgstat_report_stat(false);
 
-		store_flush_position(commit_data->end_lsn, XactLastCommitEnd);
+		store_flush_position(commit_data->end_lsn, XactLastCommitEnd,
+							 InvalidTransactionId);
 	}
 	else
 	{
@@ -3317,7 +3556,8 @@ apply_handle_relation(StringInfo s)
 {
 	LogicalRepRelation *rel;
 
-	if (handle_streamed_transaction(LOGICAL_REP_MSG_RELATION, s))
+	if (handle_parallelized_transaction(LOGICAL_REP_MSG_RELATION, s) ||
+		handle_streamed_transaction(LOGICAL_REP_MSG_RELATION, s))
 		return;
 
 	rel = logicalrep_read_rel(s);
@@ -3325,6 +3565,8 @@ apply_handle_relation(StringInfo s)
 
 	/* Also reset all entries in the partition map that refer to remoterel. */
 	logicalrep_partmap_reset_relmap(rel);
+
+	pa_distribute_schema_changes_to_workers(rel);
 }
 
 /*
@@ -3340,7 +3582,8 @@ apply_handle_type(StringInfo s)
 {
 	LogicalRepTyp typ;
 
-	if (handle_streamed_transaction(LOGICAL_REP_MSG_TYPE, s))
+	if (handle_parallelized_transaction(LOGICAL_REP_MSG_TYPE, s) ||
+		handle_streamed_transaction(LOGICAL_REP_MSG_TYPE, s))
 		return;
 
 	logicalrep_read_typ(s, &typ);
@@ -3400,6 +3643,7 @@ apply_handle_insert(StringInfo s)
 	 * streamed transactions.
 	 */
 	if (is_skipping_changes() ||
+		handle_parallelized_transaction(LOGICAL_REP_MSG_INSERT, s) ||
 		handle_streamed_transaction(LOGICAL_REP_MSG_INSERT, s))
 		return;
 
@@ -3560,6 +3804,7 @@ apply_handle_update(StringInfo s)
 	 * streamed transactions.
 	 */
 	if (is_skipping_changes() ||
+		handle_parallelized_transaction(LOGICAL_REP_MSG_UPDATE, s) ||
 		handle_streamed_transaction(LOGICAL_REP_MSG_UPDATE, s))
 		return;
 
@@ -3784,6 +4029,7 @@ apply_handle_delete(StringInfo s)
 	 * streamed transactions.
 	 */
 	if (is_skipping_changes() ||
+		handle_parallelized_transaction(LOGICAL_REP_MSG_DELETE, s) ||
 		handle_streamed_transaction(LOGICAL_REP_MSG_DELETE, s))
 		return;
 
@@ -4420,6 +4666,7 @@ apply_handle_truncate(StringInfo s)
 	 * streamed transactions.
 	 */
 	if (is_skipping_changes() ||
+		handle_parallelized_transaction(LOGICAL_REP_MSG_TRUNCATE, s) ||
 		handle_streamed_transaction(LOGICAL_REP_MSG_TRUNCATE, s))
 		return;
 
@@ -4650,6 +4897,10 @@ apply_dispatch(StringInfo s)
  * check which entries on it are already locally flushed. Those we can report
  * as having been flushed.
  *
+ * For non-streaming transactions managed by a parallel apply worker, we will
+ * get the local commit end from the shared parallel apply worker info once the
+ * transaction has been committed by the worker.
+ *
  * The have_pending_txes is true if there are outstanding transactions that
  * need to be flushed.
  */
@@ -4659,6 +4910,7 @@ get_flush_position(XLogRecPtr *write, XLogRecPtr *flush,
 {
 	dlist_mutable_iter iter;
 	XLogRecPtr	local_flush = GetFlushRecPtr(NULL);
+	List	   *committed_pa_xid = NIL;
 
 	*write = InvalidXLogRecPtr;
 	*flush = InvalidXLogRecPtr;
@@ -4668,6 +4920,40 @@ get_flush_position(XLogRecPtr *write, XLogRecPtr *flush,
 		FlushPosition *pos =
 			dlist_container(FlushPosition, node, iter.cur);
 
+		/*
+		 * If the transaction was assigned to a parallel apply worker, attempt
+		 * to retrieve its commit end LSN from that worker.
+		 */
+		if (TransactionIdIsValid(pos->pa_remote_xid) &&
+			!XLogRecPtrIsValid(pos->local_end))
+		{
+			bool		skipped_write;
+
+			pos->local_end = pa_get_last_commit_end(pos->pa_remote_xid, true,
+													&skipped_write);
+
+			elog(DEBUG1,
+				 "got commit end from parallel apply worker, "
+				 "txn: %u, remote_end %X/%X, local_end %X/%X",
+				 pos->pa_remote_xid, LSN_FORMAT_ARGS(pos->remote_end),
+				 LSN_FORMAT_ARGS(pos->local_end));
+
+			/*
+			 * Break the loop if the worker has not finished applying the
+			 * transaction. There's no need to check subsequent transactions,
+			 * as they must commit after the current transaction being
+			 * examined and thus won't have their commit end available yet.
+			 */
+			if (!skipped_write && XLogRecPtrIsInvalid(pos->local_end))
+				break;
+
+			committed_pa_xid = lappend_xid(committed_pa_xid, pos->pa_remote_xid);
+		}
+
+		/*
+		 * Worker has finished applying or the transaction was applied in the
+		 * leader apply worker.
+		 */
 		*write = pos->remote_end;
 
 		if (pos->local_end <= local_flush)
@@ -4676,29 +4962,22 @@ get_flush_position(XLogRecPtr *write, XLogRecPtr *flush,
 			dlist_delete(iter.cur);
 			pfree(pos);
 		}
-		else
-		{
-			/*
-			 * Don't want to uselessly iterate over the rest of the list which
-			 * could potentially be long. Instead get the last element and
-			 * grab the write position from there.
-			 */
-			pos = dlist_tail_element(FlushPosition, node,
-									 &lsn_mapping);
-			*write = pos->remote_end;
-			*have_pending_txes = true;
-			return;
-		}
 	}
 
 	*have_pending_txes = !dlist_is_empty(&lsn_mapping);
+
+	delete_replica_identity_entries_for_txns(committed_pa_xid);
 }
 
 /*
  * Store current remote/local lsn pair in the tracking list.
+ *
+ * pa_remote_xid should be a valid transaction ID only when the transaction was
+ * assigned to a parallel apply worker; otherwise, pass InvalidTransactionId.
  */
 void
-store_flush_position(XLogRecPtr remote_lsn, XLogRecPtr local_lsn)
+store_flush_position(XLogRecPtr remote_lsn, XLogRecPtr local_lsn,
+					 TransactionId pa_remote_xid)
 {
 	FlushPosition *flushpos;
 
@@ -4716,7 +4995,7 @@ store_flush_position(XLogRecPtr remote_lsn, XLogRecPtr local_lsn)
 	flushpos = palloc_object(FlushPosition);
 	flushpos->local_end = local_lsn;
 	flushpos->remote_end = remote_lsn;
-	flushpos->pa_remote_xid = InvalidTransactionId;
+	flushpos->pa_remote_xid = pa_remote_xid;
 
 	dlist_push_tail(&lsn_mapping, &flushpos->node);
 	MemoryContextSwitchTo(ApplyMessageContext);
@@ -6841,6 +7120,22 @@ static void
 maybe_start_skipping_changes(XLogRecPtr finish_lsn)
 {
 	Assert(!is_skipping_changes());
+
+	/*
+	 * For streaming transactions applied in a parallel apply worker, the remote
+	 * end LSN is unknown, so skipping is not possible. For non-streaming
+	 * transactions, the leader determines whether to skip the transaction. If
+	 * skipping is needed, the leader simply does not send the transaction to a
+	 * parallel apply worker.
+	 */
+	if (am_parallel_apply_worker())
+		return;
+
+	/*
+	 * These assertions apply only to leader apply and table sync workers.
+	 * Parallel workers may apply spooled BEGIN or STREAM_START messages, which
+	 * can set these flags to true.
+	 */
 	Assert(!in_remote_transaction);
 	Assert(!in_streamed_transaction);
 
diff --git a/src/include/replication/logicalproto.h b/src/include/replication/logicalproto.h
index cb0a8f440c0..d9f375295de 100644
--- a/src/include/replication/logicalproto.h
+++ b/src/include/replication/logicalproto.h
@@ -259,6 +259,8 @@ extern void logicalrep_write_message(StringInfo out, TransactionId xid, XLogRecP
 extern void logicalrep_write_rel(StringInfo out, TransactionId xid,
 								 Relation rel, Bitmapset *columns,
 								 PublishGencolsType include_gencols_type);
+extern void logicalrep_write_internal_rel(StringInfo out,
+										  LogicalRepRelation *rel);
 extern LogicalRepRelation *logicalrep_read_rel(StringInfo in);
 extern void logicalrep_write_typ(StringInfo out, TransactionId xid,
 								 Oid typoid);
diff --git a/src/include/replication/logicalrelation.h b/src/include/replication/logicalrelation.h
index 09890cef8d4..cd852337165 100644
--- a/src/include/replication/logicalrelation.h
+++ b/src/include/replication/logicalrelation.h
@@ -58,6 +58,8 @@ extern void logicalrep_rel_close(LogicalRepRelMapEntry *rel,
 								 LOCKMODE lockmode);
 extern bool IsIndexUsableForReplicaIdentityFull(Relation idxrel, AttrMap *attrmap);
 extern Oid	GetRelationIdentityOrPK(Relation rel);
+extern int	logicalrep_get_num_rels(void);
+extern void logicalrep_write_all_rels(StringInfo out);
 extern LogicalRepRelMapEntry *logicalrep_get_relentry(LogicalRepRelId remoteid);
 
 #endif							/* LOGICALRELATION_H */
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index 3c88d9e1027..02a165ccc0d 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -345,7 +345,8 @@ extern void SetupApplyOrSyncWorker(int worker_slot);
 
 extern void DisableSubscriptionAndExit(void);
 
-extern void store_flush_position(XLogRecPtr remote_lsn, XLogRecPtr local_lsn);
+extern void store_flush_position(XLogRecPtr remote_lsn, XLogRecPtr local_lsn,
+								 TransactionId pa_remote_xid);
 
 /* Function for apply error callback */
 extern void apply_error_callback(void *arg);
@@ -354,13 +355,15 @@ extern void set_apply_error_context_origin(char *originname);
 /* Parallel apply worker setup and interactions */
 extern void pa_allocate_worker(TransactionId xid, bool stream_txn);
 extern ParallelApplyWorkerInfo *pa_find_worker(TransactionId xid);
-extern XLogRecPtr pa_get_last_commit_end(TransactionId xid, bool *skipped_write);
+extern XLogRecPtr pa_get_last_commit_end(TransactionId xid, bool delete_entry,
+										 bool *skipped_write);
 extern void pa_detach_all_error_mq(void);
 
 extern void apply_handle_internal_message(StringInfo s);
 
 extern bool pa_send_data(ParallelApplyWorkerInfo *winfo, Size nbytes,
 						 const void *data);
+extern void pa_distribute_schema_changes_to_workers(LogicalRepRelation *rel);
 extern void pa_switch_to_partial_serialize(ParallelApplyWorkerInfo *winfo,
 										   bool stream_locked);
 
@@ -386,8 +389,9 @@ extern void pa_decr_and_wait_stream_block(void);
 extern void pa_xact_finish(ParallelApplyWorkerInfo *winfo,
 						   XLogRecPtr remote_lsn);
 extern bool pa_transaction_committed(TransactionId xid);
-
+extern void pa_commit_transaction(void);
 extern void pa_wait_for_depended_transaction(TransactionId xid);
+extern void pa_add_parallelized_transaction(TransactionId xid);
 
 #define isParallelApplyWorker(worker) ((worker)->in_use && \
 									   (worker)->type == WORKERTYPE_PARALLEL_APPLY)
diff --git a/src/test/subscription/meson.build b/src/test/subscription/meson.build
index fdc04d91f41..48ae698e786 100644
--- a/src/test/subscription/meson.build
+++ b/src/test/subscription/meson.build
@@ -48,7 +48,7 @@ tests += {
       't/036_sequences.pl',
       't/037_except.pl',
       't/038_walsnd_shutdown_timeout.pl',
-      't/039_row_filter_toast_ri.pl',
+      't/050_parallel_apply.pl',
       't/100_bugs.pl',
     ],
   },
diff --git a/src/test/subscription/t/001_rep_changes.pl b/src/test/subscription/t/001_rep_changes.pl
index 7d41715ed81..c863b430bec 100644
--- a/src/test/subscription/t/001_rep_changes.pl
+++ b/src/test/subscription/t/001_rep_changes.pl
@@ -16,6 +16,8 @@ $node_publisher->start;
 # Create subscriber node
 my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
 $node_subscriber->init;
+$node_subscriber->append_conf('postgresql.conf',
+	"max_logical_replication_workers = 10");
 $node_subscriber->start;
 
 # Create some preexisting content on publisher
diff --git a/src/test/subscription/t/010_truncate.pl b/src/test/subscription/t/010_truncate.pl
index 945505d0239..e15a6bb2a03 100644
--- a/src/test/subscription/t/010_truncate.pl
+++ b/src/test/subscription/t/010_truncate.pl
@@ -17,7 +17,7 @@ $node_publisher->start;
 my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
 $node_subscriber->init;
 $node_subscriber->append_conf('postgresql.conf',
-	qq(max_logical_replication_workers = 6));
+	qq(max_logical_replication_workers = 7));
 $node_subscriber->start;
 
 my $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
diff --git a/src/test/subscription/t/015_stream.pl b/src/test/subscription/t/015_stream.pl
index ac96bc3f009..705bc83f5ea 100644
--- a/src/test/subscription/t/015_stream.pl
+++ b/src/test/subscription/t/015_stream.pl
@@ -232,6 +232,12 @@ $node_subscriber->wait_for_log(
 
 $node_publisher->safe_psql('postgres', "INSERT INTO test_tab_2 values(1)");
 
+# FIXME: Currently, non-streaming transactions are applied in parallel by
+# default. So, the first transaction is handled by a parallel apply worker. To
+# trigger the deadlock, initiate an more transaction to be applied by the
+# leader.
+$node_publisher->safe_psql('postgres', "INSERT INTO test_tab_2 values(1)");
+
 $h->query_safe('COMMIT');
 $h->quit;
 
@@ -247,7 +253,7 @@ $node_publisher->wait_for_catchup($appname);
 
 $result =
   $node_subscriber->safe_psql('postgres', "SELECT count(*) FROM test_tab_2");
-is($result, qq(5001), 'data replicated to subscriber after dropping index');
+is($result, qq(5002), 'data replicated to subscriber after dropping index');
 
 # Clean up test data from the environment.
 $node_publisher->safe_psql('postgres', "TRUNCATE TABLE test_tab_2");
diff --git a/src/test/subscription/t/026_stats.pl b/src/test/subscription/t/026_stats.pl
index 5d457060a02..911005bde20 100644
--- a/src/test/subscription/t/026_stats.pl
+++ b/src/test/subscription/t/026_stats.pl
@@ -16,6 +16,7 @@ $node_publisher->start;
 # Create subscriber node.
 my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
 $node_subscriber->init;
+$node_subscriber->append_conf('postgresql.conf', "max_logical_replication_workers = 10");
 $node_subscriber->start;
 
 
diff --git a/src/test/subscription/t/027_nosuperuser.pl b/src/test/subscription/t/027_nosuperuser.pl
index 322f5b4cc6a..fdfc44ac729 100644
--- a/src/test/subscription/t/027_nosuperuser.pl
+++ b/src/test/subscription/t/027_nosuperuser.pl
@@ -86,6 +86,7 @@ $node_publisher = PostgreSQL::Test::Cluster->new('publisher');
 $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
 $node_publisher->init(allows_streaming => 'logical');
 $node_subscriber->init;
+$node_subscriber->append_conf('postgresql.conf', "max_logical_replication_workers = 10");
 $node_publisher->start;
 $node_subscriber->start;
 $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
diff --git a/src/test/subscription/t/050_parallel_apply.pl b/src/test/subscription/t/050_parallel_apply.pl
new file mode 100644
index 00000000000..9d6026470d7
--- /dev/null
+++ b/src/test/subscription/t/050_parallel_apply.pl
@@ -0,0 +1,324 @@
+
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+# This test verifies that a non-streamed transaction can launch a parallel apply
+# worker, and that dependency tracking and commit order preservation work
+# correctly during parallel apply.
+
+use strict;
+use warnings;
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+if ($ENV{enable_injection_points} ne 'yes')
+{
+	plan skip_all => 'Injection points not supported by this build';
+}
+
+# Initialize publisher node
+my $node_publisher = PostgreSQL::Test::Cluster->new('publisher');
+$node_publisher->init(allows_streaming => 'logical');
+$node_publisher->start;
+
+# Create tables and insert initial data
+$node_publisher->safe_psql(
+    'postgres', qq(
+    CREATE TABLE regress_tab (id int PRIMARY KEY, value text);
+
+    CREATE TABLE tab_ri_full (id int, value text);
+    ALTER TABLE tab_ri_full REPLICA IDENTITY FULL;
+    INSERT INTO tab_ri_full VALUES (1, 'test');
+
+    CREATE TABLE tab_toast (a text NOT NULL, b text NOT NULL);
+    ALTER TABLE tab_toast ALTER COLUMN a SET STORAGE EXTERNAL;
+    CREATE UNIQUE INDEX tab_toast_ri_index on tab_toast (a, b);
+    ALTER TABLE tab_toast REPLICA IDENTITY USING INDEX tab_toast_ri_index;
+    INSERT INTO tab_toast(a, b) VALUES(repeat('1234567890', 200), '1234567890');
+));
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(1, 10), 'test');");
+
+# Create a publication
+$node_publisher->safe_psql('postgres',
+    "CREATE PUBLICATION regress_pub FOR ALL TABLES;");
+
+# Initialize subscriber node
+my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
+$node_subscriber->init;
+$node_subscriber->append_conf('postgresql.conf', "log_min_messages = debug1");
+$node_subscriber->append_conf('postgresql.conf',
+	"max_logical_replication_workers = 10");
+$node_subscriber->start;
+
+# Check if the extension injection_points is available, as it may be
+# possible that this script is run with installcheck, where the module
+# would not be installed by default.
+if (!$node_subscriber->check_extension('injection_points'))
+{
+	plan skip_all => 'Extension injection_points not installed';
+}
+
+$node_subscriber->safe_psql('postgres', 'CREATE EXTENSION injection_points;');
+
+# Create a subscription
+my $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
+
+$node_subscriber->safe_psql(
+    'postgres', qq(
+    CREATE TABLE regress_tab (id int PRIMARY KEY, value text);
+
+    CREATE TABLE tab_ri_full (id int, value text);
+    ALTER TABLE tab_ri_full REPLICA IDENTITY FULL;
+
+    CREATE TABLE tab_toast (a text NOT NULL, b text NOT NULL);
+    ALTER TABLE tab_toast ALTER COLUMN a SET STORAGE EXTERNAL;
+    CREATE UNIQUE INDEX tab_toast_ri_index on tab_toast (a, b);
+    ALTER TABLE tab_toast REPLICA IDENTITY USING INDEX tab_toast_ri_index;
+));
+$node_subscriber->safe_psql('postgres',
+    "CREATE SUBSCRIPTION regress_sub CONNECTION '$publisher_connstr' PUBLICATION regress_pub;");
+
+# Wait for initial table sync to finish
+$node_subscriber->wait_for_subscription_sync($node_publisher, 'regress_sub');
+
+##################################################
+# Test that a non-streamed transaction can be applied in a parallel apply worker
+##################################################
+
+# Insert tuples on publisher
+#
+# XXX This may not enough to launch a parallel apply worker, because
+# table_states_not_ready is not discarded yet.
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(11, 20), 'test');");
+$node_publisher->wait_for_catchup('regress_sub');
+
+# Insert tuples again
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(21, 30), 'test');");
+$node_publisher->wait_for_catchup('regress_sub');
+
+# Verify the parallel apply worker is launched
+my $result = $node_subscriber->safe_psql('postgres',
+    "SELECT count(1) FROM pg_stat_activity WHERE backend_type = 'logical replication parallel worker'");
+is($result, '1', "parallel apply worker is launched by a non-streamed transaction");
+
+##################################################
+# Test that the basic replica identity dependency tracking and commit order
+# preservation work correctly during parallel apply.
+##################################################
+
+# Attach an injection_point. Parallel workers would wait before the commit
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+# Insert tuples on publisher
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(31, 40), 'test');");
+
+# Wait until the parallel worker enters the injection point.
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+my $offset = -s $node_subscriber->logfile;
+
+# Insert tuples on publisher again. This transaction is independent from the
+# previous one, but the parallel worker would wait till it finishes
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (generate_series(41, 50), 'test');");
+
+# Verify the parallel worker waits for the transaction
+my $str = $node_subscriber->wait_for_log(qr/wait for depended xid ([1-9][0-9]+)/, $offset);
+my $xid = $str =~ /wait for depended xid ([1-9][0-9]+)/;
+
+ok(1, "commit order dependency detected for parallel apply");
+
+$offset = -s $node_subscriber->logfile;
+
+# Update tuples which have not been applied yet on subscriber because the
+# parallel worker stops at the injection point. Newly assigned worker also
+# waits for the same transactions as above.
+$node_publisher->safe_psql('postgres',
+    "UPDATE regress_tab SET value = 'updated' WHERE id BETWEEN 31 AND 35;");
+
+# Verify the dependency is detected for the update
+$node_subscriber->wait_for_log(qr/found conflicting replica identity change on table [1-9][0-9]+ from $xid/, $offset);
+
+# Verify the parallel worker waits for the same transaction
+$node_subscriber->wait_for_log(qr/wait for depended xid $xid/, $offset);
+
+ok(1, "replica identity dependency detected for parallel apply");
+
+# Wakeup the parallel worker. We detach first no to stop other parallel workers
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+# Verify the parallel worker wakes up
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+
+$node_publisher->wait_for_catchup('regress_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab");
+is ($result, 50, 'inserts are replicated to subscriber');
+
+$result =
+  $node_subscriber->safe_psql('postgres',
+    "SELECT count(1) FROM regress_tab WHERE value = 'updated'");
+is ($result, 5, 'updates are also replicated to subscriber');
+
+##################################################
+# Test that the dependency tracking works correctly for unchanged toasted RI
+# columns.
+##################################################
+
+# Attach an injection_point. Parallel workers would wait before the commit
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+# Update one replica identity column but keep toasted column unchanged
+$node_publisher->safe_psql('postgres',
+    "UPDATE tab_toast SET b = '1';");
+
+# Wait until the parallel worker enters the injection point.
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+$offset = -s $node_subscriber->logfile;
+
+# Delete the updated row.
+$node_publisher->safe_psql('postgres',
+    "DELETE FROM tab_toast WHERE b = '1';");
+
+# Verify the dependency is detected for the delete
+$str = $node_subscriber->wait_for_log(qr/found conflicting replica identity change on table [1-9][0-9]+ from ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /found conflicting replica identity change on table [1-9][0-9]+ from ([1-9][0-9]+)/;
+
+# Verify the parallel worker waits for the same transaction
+$node_subscriber->wait_for_log(qr/wait for depended xid $xid/, $offset);
+
+ok(1, "replica identity dependency from unchanged toasted column detected for parallel apply");
+
+# Wakeup the parallel worker. We detach first no to stop other parallel workers
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+# Verify the parallel worker wakes up
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+
+$node_publisher->wait_for_catchup('regress_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM tab_toast");
+is ($result, 0, 'changes are replicated to subscriber');
+
+##################################################
+# Test that dependency tracking still works for REPLICA IDENTITY FULL when
+# new tuple includes NULL key values.
+##################################################
+
+# Attach an injection_point. Parallel workers would wait before the commit
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+# Update one row to a non-NULL value and block commit in parallel worker.
+$node_publisher->safe_psql('postgres',
+        "UPDATE tab_ri_full SET value = NULL WHERE id = 1;");
+
+# Wait until the parallel worker enters the injection point.
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+$offset = -s $node_subscriber->logfile;
+
+# This update sets a NULL value under REPLICA IDENTITY FULL. We must still
+# detect dependency on the preceding update of the same row.
+$node_publisher->safe_psql('postgres',
+        "UPDATE tab_ri_full SET value = 'test' WHERE id = 1;");
+
+# Verify the dependency is detected for the update with NULL key value.
+$str = $node_subscriber->wait_for_log(qr/found conflicting replica identity change on table [1-9][0-9]+ from ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /found conflicting replica identity change on table [1-9][0-9]+ from ([1-9][0-9]+)/;
+
+# Verify the parallel worker waits for the same transaction.
+$node_subscriber->wait_for_log(qr/wait for depended xid $xid/, $offset);
+
+ok(1, "replica identity FULL dependency with NULL values detected for parallel apply");
+
+# Wakeup the parallel worker. We detach first no to stop other parallel workers
+$node_subscriber->safe_psql('postgres', qq[
+        SELECT injection_points_detach('parallel-worker-before-commit');
+        SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+# Verify the parallel worker wakes up.
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+
+$node_publisher->wait_for_catchup('regress_sub');
+
+$result =
+    $node_subscriber->safe_psql('postgres',
+        "SELECT count(1) FROM tab_ri_full WHERE id = 1 AND value = 'test'");
+is ($result, 1, 'update is replicated for REPLICA IDENTITY FULL table');
+
+##################################################
+# Test that table level dependency tracking by TRUNCATE work correctly during
+# parallel apply.
+##################################################
+
+# Truncate the data for upcoming tests
+$node_publisher->safe_psql('postgres', "TRUNCATE TABLE regress_tab;");
+$node_publisher->wait_for_catchup('regress_sub');
+
+# Attach an injection_point. Parallel workers would wait before the commit
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-commit','wait');"
+);
+
+# Insert tuples on publisher
+$node_publisher->safe_psql('postgres',
+    "TRUNCATE regress_tab;");
+
+# Wait until the parallel worker enters the injection point.
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-commit');
+
+$offset = -s $node_subscriber->logfile;
+
+# Insert a tuple that conflicts with the TRUNCATE operation.
+$node_publisher->safe_psql('postgres',
+    "INSERT INTO regress_tab VALUES (1, 'test');");
+
+# Verify the dependency is detected for the insert
+$str = $node_subscriber->wait_for_log(qr/found table-wide change affecting [1-9][0-9]+ from ([1-9][0-9]+)/, $offset);
+$xid = $str =~ /found table-wide change affecting [1-9][0-9]+ from ([1-9][0-9]+)/;
+
+ok(1, "table-wide dependency from TRUNCATE detected for parallel apply");
+
+# Wakeup the parallel worker. We detach first no to stop other parallel workers
+$node_subscriber->safe_psql('postgres', qq[
+    SELECT injection_points_detach('parallel-worker-before-commit');
+    SELECT injection_points_wakeup('parallel-worker-before-commit');
+]);
+
+# Verify the parallel worker waits for the same transaction
+$node_subscriber->wait_for_log(qr/wait for depended xid $xid/, $offset);
+
+# Verify the parallel worker wakes up
+$node_subscriber->wait_for_log(qr/finish waiting for depended xid $xid/, $offset);
+
+$node_publisher->wait_for_catchup('regress_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT count(1) FROM regress_tab");
+is ($result, 1, 'inserts are replicated to subscriber');
+
+done_testing();
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 9609fd05967..c9b24dbfa59 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2164,6 +2164,7 @@ ParallelHashGrowth
 ParallelHashJoinBatch
 ParallelHashJoinBatchAccessor
 ParallelHashJoinState
+ParallelizedTxnEntry
 ParallelIndexScanDesc
 ParallelizedTxnEntry
 ParallelSlot
@@ -4263,6 +4264,7 @@ rendezvousHashEntry
 rep
 replace_rte_variables_callback
 replace_rte_variables_context
+replica_identity_hash
 report_error_fn
 ret_type
 rewind_source
-- 
2.43.0



  [application/octet-stream] v21-0002-Introduce-internal-messages-to-track-dependencie.patch (12.7K, ../../TY4PR01MB177180394D8E9C6D263E117E494FE2@TY4PR01MB17718.jpnprd01.prod.outlook.com/10-v21-0002-Introduce-internal-messages-to-track-dependencie.patch)
  download | inline diff:
From 45a6e95e81cc275be655e4006112b650bc4cf63d Mon Sep 17 00:00:00 2001
From: Zhijie Hou <[email protected]>
Date: Wed, 29 Apr 2026 16:41:44 +0800
Subject: [PATCH v21 2/9] Introduce internal messages to track dependencies

This patch introduces a new set of internal worker message types (see
PAWorkerMsgType). These types of messages are generated by a leader worker
and sent to parallel apply workers based on the needs. For now, two types of
messages exist: PA_MSG_XACT_DEPENDENCY and PA_MSG_RELMAP.

PA_MSG_XACT_DEPENDENCY ensures that dependent transactions are
committed in the correct order. It has a list of transaction IDs that parallel
workers must wait for. This type of message is generated when the leader
detects a dependency between the current and other transactions, or just before
the COMMIT message. The latter one is used to preserve the commit ordering
between the publisher and the subscriber.

PA_MSG_RELMAP is used to synchronize the remote relation
information between the leader and parallel workers. It has a list of relations
that the leader already knows, and parallel workers also update the relmap in
response to the message. This type of message is generated when the leader
allocates a new parallel worker to the transaction, or when the publisher sends
additional RELATION messages. This synchronization is necessary for parallel
apply workers to map local replication target relations to their remote
counterparts during change application. Since the walsender does not send remote
relation information with every transaction, the parallel apply worker may not
have up-to-date relation info unless synchronized by the leader.

In the logical replication protocol, the above messages are encapsulated within
the LOGICAL_REP_MSG_INTERNAL_MESSAGE type. Later patches will use a uniform
format (LOGICAL_REP_MSG_INTERNAL_MESSAGE + PAWorkerMsgType + internal data) for
sending to parallel apply workers or serializing to disk.

Author: Zhijie Hou <[email protected]>
Author: Hayato Kuroda <[email protected]>
---
 .../replication/logical/applyparallelworker.c | 187 ++++++++++++++++--
 src/backend/replication/logical/proto.c       |   2 +
 src/backend/replication/logical/worker.c      |   4 +
 src/include/replication/logicalproto.h        |   8 +
 src/include/replication/worker_internal.h     |  17 ++
 src/tools/pgindent/typedefs.list              |   1 +
 6 files changed, 202 insertions(+), 17 deletions(-)

diff --git a/src/backend/replication/logical/applyparallelworker.c b/src/backend/replication/logical/applyparallelworker.c
index 5eb0679e850..efde9181a34 100644
--- a/src/backend/replication/logical/applyparallelworker.c
+++ b/src/backend/replication/logical/applyparallelworker.c
@@ -772,6 +772,73 @@ ProcessParallelApplyInterrupts(void)
 	}
 }
 
+/*
+ * Handle internal dependency information.
+ *
+ * Wait for all transactions listed in the message to commit.
+ */
+static void
+apply_handle_internal_dependency(StringInfo s)
+{
+	int			nxids = pq_getmsgint(s, 4);
+
+	for (int i = 0; i < nxids; i++)
+	{
+		TransactionId xid = pq_getmsgint(s, 4);
+
+		pa_wait_for_depended_transaction(xid);
+	}
+}
+
+/*
+ * Handle internal relation information.
+ *
+ * Update all relation details in the relation map cache.
+ */
+static void
+apply_handle_internal_relation(StringInfo s)
+{
+	int			nrels = pq_getmsgint(s, 4);
+
+	for (int i = 0; i < nrels; i++)
+	{
+		LogicalRepRelation *rel = logicalrep_read_rel(s);
+
+		logicalrep_relmap_update(rel);
+
+		/* Also reset all entries in the partition map that refer to remoterel. */
+		logicalrep_partmap_reset_relmap(rel);
+
+		elog(DEBUG1, "parallel apply worker init relmap for %s",
+			 rel->relname);
+	}
+}
+
+/*
+ * Handle an internal message generated by the leader apply worker.
+ */
+void
+apply_handle_internal_message(StringInfo s)
+{
+	PAWorkerMsgType action = pq_getmsgbyte(s);
+
+	Assert(am_parallel_apply_worker());
+
+	switch (action)
+	{
+		case PA_MSG_XACT_DEPENDENCY:
+			apply_handle_internal_dependency(s);
+			break;
+		case PA_MSG_RELMAP:
+			apply_handle_internal_relation(s);
+			break;
+		default:
+			ereport(ERROR,
+					(errcode(ERRCODE_PROTOCOL_VIOLATION),
+					 errmsg("invalid worker internal message type \"??? (%d)\"", action)));
+	}
+}
+
 /* Parallel apply worker main loop. */
 static void
 LogicalParallelApplyLoop(shm_mq_handle *mqh)
@@ -780,6 +847,14 @@ LogicalParallelApplyLoop(shm_mq_handle *mqh)
 	ErrorContextCallback errcallback;
 	MemoryContext oldcxt = CurrentMemoryContext;
 
+	/*
+	 * Ensure LOGICAL_REP_MSG_INTERNAL_MESSAGE does not conflict with
+	 * PqReplMsg_WALData ('d'), as parallel apply workers may receive both types
+	 * of messages.
+	 */
+	StaticAssertDecl(LOGICAL_REP_MSG_INTERNAL_MESSAGE != PqReplMsg_WALData,
+					 "LOGICAL_REP_MSG_INTERNAL_MESSAGE conflicts with PqReplMsg_WALData");
+
 	/*
 	 * Init the ApplyMessageContext which we clean up after each replication
 	 * protocol message.
@@ -818,26 +893,46 @@ LogicalParallelApplyLoop(shm_mq_handle *mqh)
 
 			initReadOnlyStringInfo(&s, data, len);
 
-			/*
-			 * The first byte of messages sent from leader apply worker to
-			 * parallel apply workers can only be PqReplMsg_WALData.
-			 */
 			c = pq_getmsgbyte(&s);
-			if (c != PqReplMsg_WALData)
-				elog(ERROR, "unexpected message \"%c\"", c);
 
-			/*
-			 * Ignore statistics fields that have been updated by the leader
-			 * apply worker.
-			 *
-			 * XXX We can avoid sending the statistics fields from the leader
-			 * apply worker but for that, it needs to rebuild the entire
-			 * message by removing these fields which could be more work than
-			 * simply ignoring these fields in the parallel apply worker.
-			 */
-			s.cursor += SIZE_STATS_MESSAGE;
+			if (c == PqReplMsg_WALData)
+			{
+				/*
+				 * Ignore statistics fields that have been updated by the
+				 * leader apply worker.
+				 *
+				 * XXX We can avoid sending the statistics fields from the
+				 * leader apply worker but for that, it needs to rebuild the
+				 * entire message by removing these fields which could be more
+				 * work than simply ignoring these fields in the parallel apply
+				 * worker.
+				 */
+				s.cursor += SIZE_STATS_MESSAGE;
+
+				apply_dispatch(&s);
+			}
+			else if (c == LOGICAL_REP_MSG_INTERNAL_MESSAGE)
+			{
+				/*
+				 * Rewind the cursor so that apply_dispatch can re-read the
+				 * first byte (LOGICAL_REP_MSG_INTERNAL_MESSAGE) to correctly
+				 * handle the internal message. Alternatively, we could call
+				 * apply_handle_internal_message directly, but using
+				 * apply_dispatch uniformly across all message types is cleaner
+				 * and more consistent.
+				 */
+				s.cursor--;
 
-			apply_dispatch(&s);
+				apply_dispatch(&s);
+			}
+			else
+			{
+				/*
+				 * The first byte of messages sent from leader apply worker to
+				 * parallel apply workers can only be 'w' or 'i'.
+				 */
+				elog(ERROR, "unexpected message \"%c\"", c);
+			}
 		}
 		else if (shmq_res == SHM_MQ_WOULD_BLOCK)
 		{
@@ -1744,3 +1839,61 @@ pa_attach_parallelized_txn_hash(dsa_handle *pa_dsa_handle,
 
 	MemoryContextSwitchTo(oldctx);
 }
+
+/*
+ * Wait for the given remote transaction to finish applying by a parallel apply
+ * worker.
+ *
+ * Both leader and parallel apply workers can call this function to wait for a
+ * parallelized transaction to finish.
+ */
+void
+pa_wait_for_depended_transaction(TransactionId xid)
+{
+	/*
+	 * Quick exit if parallelized_txns has not been initialized yet. This can
+	 * happen when the leader worker calls this function before any parallel
+	 * apply workers have been launched.
+	 */
+	if (!parallelized_txns)
+		return;
+
+	elog(DEBUG1, "wait for depended xid %u", xid);
+
+	for (;;)
+	{
+		ParallelizedTxnEntry *txn_entry;
+
+		txn_entry = dshash_find(parallelized_txns, &xid, false);
+
+		/* The entry is removed only if the transaction is committed */
+		if (txn_entry == NULL)
+			break;
+
+		dshash_release_lock(parallelized_txns, txn_entry);
+
+		/*
+		 * Wait for the parallel apply worker processing the given remote
+		 * transaction to finish applying and release its lock.
+		 */
+		pa_lock_transaction(xid, AccessShareLock);
+		pa_unlock_transaction(xid, AccessShareLock);
+
+		CHECK_FOR_INTERRUPTS();
+
+		/*
+		 * Acquiring the lock successfully does not guarantee we can proceed.
+		 * The worker may have errored out and released the lock while leaving
+		 * its shared hash entry intact, or it may not have acquired the lock
+		 * yet because it hasn't processed the BEGIN message. In either case, we
+		 * must continue waiting in the loop until the parallel apply worker
+		 * finishes applying the transaction, or until the leader notifies us of
+		 * a failure and restarts all workers.
+		 *
+		 * The above race window is small and infrequent, so no WaitLatch is
+		 * added.
+		 */
+	}
+
+	elog(DEBUG1, "finish waiting for depended xid %u", xid);
+}
diff --git a/src/backend/replication/logical/proto.c b/src/backend/replication/logical/proto.c
index 86ad97cd937..a0cac05cdcd 100644
--- a/src/backend/replication/logical/proto.c
+++ b/src/backend/replication/logical/proto.c
@@ -1253,6 +1253,8 @@ logicalrep_message_type(LogicalRepMsgType action)
 			return "STREAM ABORT";
 		case LOGICAL_REP_MSG_STREAM_PREPARE:
 			return "STREAM PREPARE";
+		case LOGICAL_REP_MSG_INTERNAL_MESSAGE:
+			return "INTERNAL MESSAGE";
 	}
 
 	/*
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 7799266c614..bd53034cf69 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -3890,6 +3890,10 @@ apply_dispatch(StringInfo s)
 			apply_handle_stream_prepare(s);
 			break;
 
+		case LOGICAL_REP_MSG_INTERNAL_MESSAGE:
+			apply_handle_internal_message(s);
+			break;
+
 		default:
 			ereport(ERROR,
 					(errcode(ERRCODE_PROTOCOL_VIOLATION),
diff --git a/src/include/replication/logicalproto.h b/src/include/replication/logicalproto.h
index 058a955e20c..cb0a8f440c0 100644
--- a/src/include/replication/logicalproto.h
+++ b/src/include/replication/logicalproto.h
@@ -53,6 +53,13 @@
  * in logical replication protocol, which uses a single byte to identify a
  * message type. Hence the values should be single-byte wide and preferably
  * human-readable characters.
+ *
+ * LOGICAL_REP_MSG_INTERNAL_MESSAGE ('i') is reserved for internal messages (see
+ * PAWorkerMsgType for sub-message types) sent from the leader apply
+ * worker to parallel apply workers. Centralizing this definition here allows
+ * all message types to be handled together and avoids the maintenance burden of
+ * ensuring sub-message types do not conflict with regular LogicalRepMsgType
+ * values.
  */
 typedef enum LogicalRepMsgType
 {
@@ -75,6 +82,7 @@ typedef enum LogicalRepMsgType
 	LOGICAL_REP_MSG_STREAM_COMMIT = 'c',
 	LOGICAL_REP_MSG_STREAM_ABORT = 'A',
 	LOGICAL_REP_MSG_STREAM_PREPARE = 'p',
+	LOGICAL_REP_MSG_INTERNAL_MESSAGE = 'i',
 } LogicalRepMsgType;
 
 /*
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index e60e0c3d6d7..43dcae43a62 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -242,6 +242,19 @@ typedef struct ParallelApplyWorkerInfo
 	ParallelApplyWorkerShared *shared;
 } ParallelApplyWorkerInfo;
 
+/*
+ * Parallel apply worker internal message types.
+ *
+ * These types of messages are generated by the leader apply worker and sent to
+ * the parallel apply worker, encapsulated within the
+ * LOGICAL_REP_MSG_INTERNAL_MESSAGE type.
+ */
+typedef enum PAWorkerMsgType
+{
+	PA_MSG_XACT_DEPENDENCY = 'd',
+	PA_MSG_RELMAP = 'r',
+} PAWorkerMsgType;
+
 /* Main memory context for apply worker. Permanent during worker lifetime. */
 extern PGDLLIMPORT MemoryContext ApplyContext;
 
@@ -341,6 +354,8 @@ extern void pa_allocate_worker(TransactionId xid);
 extern ParallelApplyWorkerInfo *pa_find_worker(TransactionId xid);
 extern void pa_detach_all_error_mq(void);
 
+extern void apply_handle_internal_message(StringInfo s);
+
 extern bool pa_send_data(ParallelApplyWorkerInfo *winfo, Size nbytes,
 						 const void *data);
 extern void pa_switch_to_partial_serialize(ParallelApplyWorkerInfo *winfo,
@@ -368,6 +383,8 @@ extern void pa_decr_and_wait_stream_block(void);
 extern void pa_xact_finish(ParallelApplyWorkerInfo *winfo,
 						   XLogRecPtr remote_lsn);
 
+extern void pa_wait_for_depended_transaction(TransactionId xid);
+
 #define isParallelApplyWorker(worker) ((worker)->in_use && \
 									   (worker)->type == WORKERTYPE_PARALLEL_APPLY)
 #define isTableSyncWorker(worker) ((worker)->in_use && \
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 32524990758..0d5959aadb7 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -1929,6 +1929,7 @@ OverridingKind
 PACE_HEADER
 PACL
 PATH
+PAWorkerMsgType
 PCtxtHandle
 PERL_CONTEXT
 PERL_SI
-- 
2.43.0



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


end of thread, other threads:[~2026-07-09 08:08 UTC | newest]

Thread overview: 63+ messages (download: mbox mbox.gz follow: Atom feed)
-- links below jump to the message on this page --
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH v9 8/9] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH v7 8/9] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 3/6] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2021-03-12 21:35 [PATCH 08/10] Default to zstd.. Justin Pryzby <[email protected]>
2026-01-05 07:40 RE: Parallel Apply Hayato Kuroda (Fujitsu) <[email protected]>
2026-01-30 11:18 ` RE: Parallel Apply Hayato Kuroda (Fujitsu) <[email protected]>
2026-06-04 06:58 RE: Parallel Apply Zhijie Hou (Fujitsu) <[email protected]>
2026-06-09 04:14 ` Re: Parallel Apply Peter Smith <[email protected]>
2026-06-15 11:20   ` RE: Parallel Apply Zhijie Hou (Fujitsu) <[email protected]>
2026-07-09 08:08     ` RE: Parallel Apply Zhijie Hou (Fujitsu) <[email protected]>

This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox