From ab091678466269c32a98fe93356346a1fa869837 Mon Sep 17 00:00:00 2001
From: Andrey Rachitskiy <pl0h0yp1@gmail.com>
Date: Fri, 13 Nov 2026 01:43:20 +0500
Subject: [PATCH] Fix publication membership of a partition pending detach.

DETACH PARTITION CONCURRENTLY commits inhdetachpending before FINALIZE
clears relispartition. get_partition_ancestors() is then empty, and
pg_get_publication_tables() still skips the table because
relispartition is set. A FOR ALL TABLES publication with
publish_via_partition_root omits it. Use that ancestor list for the
check. An empty list leaves the table in the publication.

BUG #19884
Author: Andrey Rachitskiy <pl0h0yp1@gmail.com>
Reported-by: Milo <1548140642@qq.com>
Discussion: https://www.postgresql.org/message-id/19884-3b4fbbdf292f33f1@postgresql.org
---
 src/backend/catalog/pg_publication.c          | 38 ++++++++++++++-----
 .../isolation/expected/pub-detach-pending.out | 13 +++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../isolation/specs/pub-detach-pending.spec   | 27 +++++++++++++
 4 files changed, 70 insertions(+), 9 deletions(-)
 create mode 100644 src/test/isolation/expected/pub-detach-pending.out
 create mode 100644 src/test/isolation/specs/pub-detach-pending.spec

diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index f11ea8839ca..36aec816d83 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -1077,6 +1077,27 @@ GetAllTablesPublications(void)
 	return result;
 }
 
+/*
+ * relispartition stays set until DETACH PARTITION ... FINALIZE.
+ * get_partition_ancestors() is empty after the first transaction of
+ * DETACH PARTITION ... CONCURRENTLY commits. The table is then published
+ * as itself.
+ */
+static bool
+pub_rel_is_partition(Oid relid, bool relispartition)
+{
+	List	   *ancestors;
+	bool		result;
+
+	if (!relispartition)
+		return false;
+
+	ancestors = get_partition_ancestors(relid);
+	result = (ancestors != NIL);
+	list_free(ancestors);
+	return result;
+}
+
 /*
  * Gets list of all relations published by FOR ALL TABLES/SEQUENCES
  * publication.
@@ -1122,7 +1143,8 @@ GetAllPublicationRelations(Oid pubid, char relkind, bool pubviaroot)
 		Oid			relid = relForm->oid;
 
 		if (is_publishable_class(relid, relForm) &&
-			!(relForm->relispartition && pubviaroot) &&
+			!(pubviaroot &&
+			  pub_rel_is_partition(relid, relForm->relispartition)) &&
 			!list_member_oid(exceptlist, relid))
 			result = lappend_oid(result, relid);
 	}
@@ -1144,7 +1166,7 @@ GetAllPublicationRelations(Oid pubid, char relkind, bool pubviaroot)
 			Oid			relid = relForm->oid;
 
 			if (is_publishable_class(relid, relForm) &&
-				!relForm->relispartition &&
+				!pub_rel_is_partition(relid, relForm->relispartition) &&
 				!list_member_oid(exceptlist, relid))
 				result = lappend_oid(result, relid);
 		}
@@ -1388,10 +1410,12 @@ is_table_publishable_in_publication(Oid relid, Publication *pub)
 	if (pub->alltables)
 	{
 		/*
-		 * ALL TABLES with pubviaroot includes only regular tables or top-most
-		 * partitioned tables -- never child partitions.
+		 * ALL TABLES with pubviaroot publishes regular tables and top-most
+		 * partitioned tables. A partition that still has ancestors is covered
+		 * by its root. Pending detach leaves that list empty, so the table is
+		 * published as itself.
 		 */
-		if (pub->pubviaroot && relispartition)
+		if (pub->pubviaroot && ancestors != NIL)
 			return false;
 
 		/*
@@ -1400,10 +1424,6 @@ is_table_publishable_in_publication(Oid relid, Publication *pub)
 		 * EXCEPT clause, so exclusion must be evaluated at the top-most
 		 * ancestor if it has. These publications store only EXCEPT'ed tables
 		 * in pg_publication_rel, so checking existence is sufficient.
-		 *
-		 * Note that this existence check below would incorrectly return true
-		 * (published) for partitions when pubviaroot is enabled; however,
-		 * that case is already caught and returned false by the above check.
 		 */
 		return !SearchSysCacheExists2(PUBLICATIONRELMAP,
 									  ObjectIdGetDatum(ancestors
diff --git a/src/test/isolation/expected/pub-detach-pending.out b/src/test/isolation/expected/pub-detach-pending.out
new file mode 100644
index 00000000000..4acd019d49a
--- /dev/null
+++ b/src/test/isolation/expected/pub-detach-pending.out
@@ -0,0 +1,13 @@
+Parsed test spec with 3 sessions
+
+starting permutation: lock detach listed unlock
+step lock: BEGIN; LOCK TABLE parent_tbl IN ACCESS SHARE MODE;
+step detach: ALTER TABLE parent_tbl DETACH PARTITION child_tbl CONCURRENTLY; <waiting ...>
+step listed: SELECT tablename FROM pg_publication_tables WHERE pubname = 'pub' AND tablename = 'child_tbl';
+tablename
+---------
+child_tbl
+(1 row)
+
+step unlock: COMMIT;
+step detach: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index f448e4d6e13..e8ee37fd5e9 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -135,5 +135,6 @@ test: lock-nowait
 test: ddl-dependency-locking
 test: tablespace-dependency-locking
 test: pub-concurrent-drop
+test: pub-detach-pending
 test: type-ddl-locking
 test: drop-owned-grant
diff --git a/src/test/isolation/specs/pub-detach-pending.spec b/src/test/isolation/specs/pub-detach-pending.spec
new file mode 100644
index 00000000000..e5cc4a6bd0c
--- /dev/null
+++ b/src/test/isolation/specs/pub-detach-pending.spec
@@ -0,0 +1,27 @@
+# Pending detach commits inhdetachpending, then waits. An ALL TABLES
+# publication with publish_via_partition_root must list that partition.
+
+setup
+{
+	CREATE TABLE parent_tbl (id int) PARTITION BY RANGE (id);
+	CREATE TABLE child_tbl PARTITION OF parent_tbl FOR VALUES FROM (0) TO (100);
+	CREATE PUBLICATION pub FOR ALL TABLES WITH (publish_via_partition_root = true);
+}
+
+teardown
+{
+	DROP PUBLICATION pub;
+	DROP TABLE parent_tbl, child_tbl;
+}
+
+session s1
+step lock	{ BEGIN; LOCK TABLE parent_tbl IN ACCESS SHARE MODE; }
+step unlock	{ COMMIT; }
+
+session s2
+step detach	{ ALTER TABLE parent_tbl DETACH PARTITION child_tbl CONCURRENTLY; }
+
+session s3
+step listed	{ SELECT tablename FROM pg_publication_tables WHERE pubname = 'pub' AND tablename = 'child_tbl'; }
+
+permutation lock detach listed unlock
-- 
2.53.0

