Hi,

On Wed, Nov11, 2026 at 05:06 PM shveta malik wrote:
On Wed, Nov 11, 2026 at 1:05 PM Zhijie Hou <[email protected]> wrote:
> Since we are considering whether to push 0001 first, I reviewed it
> and have few suggestions:
>
> 1. We can remove the am_partition variable completely and simply check
> "ancestors" in the subsequent code, to be consistent with
> RelationBuildPublicationDesc.
>
> 2. The test can be simplified to cover only the decoding crash (since
> the subscriber side is not touched by this patch), which saves
> buildfarm test time. In addition, we can move the test into the
> existing isolation test to speed it up further.
>
> I have attached a top-up patch for 0001 with these changes. Feel
> free to merge it if it looks good.

I agree with the changes. Few concerns:

Thanks for reviewing.


It would be better to show in the additional steps that
pg_inherits.inhdetachpending is true before we actually try
pg_logical_slot_get_binary_changes.

I checked that no existing tests (either for DETACH CONCURRENTLY or otherwise) refer to the inhdetachpendin in their test cases, and since our test basically tests the DETACH behavior as well, I think it's fine to skip testing this.


Also I feel it would make more sense to create-pub with default
publish_via_partition_root rather than publish_via_partition_root=true
for PG19's testcase.

Changed as suggested.

After some analysis, I think creating slots in src/test/isolation/ wouldn't be stable, since some buildfarm animals may run the main regression tests under wal_level=minimal. I also didn't find slot creation in the main isolation tests. So I chose to put the test in test_decoding/concurrent_ddl_dml.spec, which has precedent for creating slots with pgoutput under test_decoding tests, so it can be acceptable.

I'm helping to merge the changes into 0001 and preparing patches for all branches, hoping to close this item soon.

--
Best Regards,
Zhijie Hou
From cf0cae917d2ba78deb69b8b9e868f7191efaab84 Mon Sep 17 00:00:00 2001
From: Mikhail Nikalayeu <[email protected]>
Date: Wed, 11 Nov 2026 19:22:01 +0800
Subject: [PATCH v3] Fix crash decoding a change to a partition pending detach.
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

ALTER TABLE ... DETACH PARTITION ... CONCURRENTLY, added by 71f4c8c6f74b,
leaves the partition marked as detaching when its wait is interrupted -- by
a lock timeout, a cancel, a disconnect. Only DETACH PARTITION ... FINALIZE
clears that mark, and the partition cannot be attached back. In that state
pg_class still says relispartition, while get_partition_ancestors() already
reports nothing.

get_rel_sync_entry() was not ready for it: it read relispartition as meaning
that the ancestor list is not empty, and asked for its last element, which is
an assertion failure, and a NULL pointer dereference without assertions.
Decoding any change to such a partition for a FOR ALL TABLES publication gets
there, and as the change is never confirmed, a subscription turns that into a
crash loop.

Fetch the ancestors once, up front, and treat a partition with no ancestors
as a standalone table, which is how it is published once the detach
completes.

The crash became reachable in v14, where 71f4c8c6f74b introduced the state,
and fd366065e06a widened it from a publication with
publish_via_partition_root to any FOR ALL TABLES one.

Reported-by: Mikhail Nikalayeu <[email protected]>
Reported-by: Nisha Moond <[email protected]>
Reported-by: Zsolt Parragi <[email protected]>
Author: Mikhail Nikalayeu <[email protected]>
Author: Zhijie Hou <[email protected]>
Reviewed-by: Álvaro Herrera <[email protected]>
Reviewed-by: Nisha Moond <[email protected]>
Reviewed-by: shveta malik <[email protected]>
Reviewed-by: Amit Kapila <[email protected]>
Reviewed-by: Masahiko Sawada <[email protected]>
Reviewed-by: Zhijie Hou <[email protected]>
Discussion: 
https://postgr.es/m/CADzfLwWoFPT%2Ba73%3DA%3DbsNWRMZQ98NpBEMgE%3Dt1FS4O4_%3DQVLfA%40mail.gmail.com
Discussion: 
https://postgr.es/m/CABdArM64ZSat%3DdB094U8OCBzp1m8_Y85m6T%2BxPEgpLVJb60_rA%40mail.gmail.com
Backpatch-through: 15
---
 .../expected/concurrent_ddl_dml.out           | 38 +++++++++++++++++++
 .../specs/concurrent_ddl_dml.spec             | 17 +++++++++
 src/backend/catalog/partition.c               |  5 +++
 src/backend/replication/pgoutput/pgoutput.c   | 20 +++++++---
 4 files changed, 74 insertions(+), 6 deletions(-)

diff --git a/contrib/test_decoding/expected/concurrent_ddl_dml.out 
b/contrib/test_decoding/expected/concurrent_ddl_dml.out
index 3742a2a2474..a09776fea8a 100644
--- a/contrib/test_decoding/expected/concurrent_ddl_dml.out
+++ b/contrib/test_decoding/expected/concurrent_ddl_dml.out
@@ -797,3 +797,41 @@ COMMIT
 stop    
 (1 row)
 
+
+starting permutation: s1_init_pgoutput s1_begin s1_select_part s2_detach_part 
s1_cancel_s2 s1_commit s1_create_pub s1_insert_part s2_get_binary_changes
+step s1_init_pgoutput: SELECT 'init' FROM 
pg_create_logical_replication_slot('isolation_slot', 'pgoutput');
+?column?
+--------
+init    
+(1 row)
+
+step s1_begin: BEGIN;
+step s1_select_part: SELECT * FROM tbl_part;
+val1
+----
+(0 rows)
+
+step s2_detach_part: ALTER TABLE tbl_part DETACH PARTITION tbl_part_p1 
CONCURRENTLY; <waiting ...>
+step s1_cancel_s2: SELECT pg_cancel_backend(pid) FROM pg_stat_activity WHERE 
application_name = 'isolation/concurrent_ddl_dml/s2'; <waiting ...>
+step s2_detach_part: <... completed>
+ERROR:  canceling statement due to user request
+step s1_cancel_s2: <... completed>
+pg_cancel_backend
+-----------------
+t                
+(1 row)
+
+step s1_commit: COMMIT;
+step s1_create_pub: CREATE PUBLICATION pub FOR ALL TABLES WITH 
(publish_via_partition_root = true);
+step s1_insert_part: INSERT INTO tbl_part_p1 VALUES (1);
+step s2_get_binary_changes: SELECT count(*) > 0 FROM 
pg_logical_slot_get_binary_changes('isolation_slot', NULL, NULL, 
'proto_version', '1', 'publication_names', 'pub');
+?column?
+--------
+t       
+(1 row)
+
+?column?
+--------
+stop    
+(1 row)
+
diff --git a/contrib/test_decoding/specs/concurrent_ddl_dml.spec 
b/contrib/test_decoding/specs/concurrent_ddl_dml.spec
index d16515e6f48..77ec8c133a5 100644
--- a/contrib/test_decoding/specs/concurrent_ddl_dml.spec
+++ b/contrib/test_decoding/specs/concurrent_ddl_dml.spec
@@ -2,12 +2,17 @@ setup
 {
     DROP TABLE IF EXISTS tbl1;
     DROP TABLE IF EXISTS tbl2;
+    DROP TABLE IF EXISTS tbl_part, tbl_part_p1;
     CREATE TABLE tbl1(val1 integer, val2 integer);
     CREATE TABLE tbl2(val1 integer, val2 integer);
+    CREATE TABLE tbl_part(val1 integer) PARTITION BY LIST (val1);
+    CREATE TABLE tbl_part_p1 PARTITION OF tbl_part FOR VALUES IN (1);
 }
 
 teardown
 {
+    DROP PUBLICATION IF EXISTS pub;
+    DROP TABLE IF EXISTS tbl_part, tbl_part_p1;
     DROP TABLE tbl1;
     DROP TABLE tbl2;
     SELECT 'stop' FROM pg_drop_replication_slot('isolation_slot');
@@ -17,10 +22,15 @@ session "s1"
 setup { SET synchronous_commit=on; }
 
 step "s1_init" { SELECT 'init' FROM 
pg_create_logical_replication_slot('isolation_slot', 'test_decoding'); }
+step "s1_init_pgoutput" { SELECT 'init' FROM 
pg_create_logical_replication_slot('isolation_slot', 'pgoutput'); }
 step "s1_begin" { BEGIN; }
 step "s1_insert_tbl1" { INSERT INTO tbl1 (val1, val2) VALUES (1, 1); }
 step "s1_insert_tbl2" { INSERT INTO tbl2 (val1, val2) VALUES (1, 1); }
 step "s1_insert_tbl2_3col" { INSERT INTO tbl2 (val1, val2, val3) VALUES (1, 1, 
1); }
+step "s1_select_part" { SELECT * FROM tbl_part; }
+step "s1_cancel_s2" { SELECT pg_cancel_backend(pid) FROM pg_stat_activity 
WHERE application_name = 'isolation/concurrent_ddl_dml/s2'; }
+step "s1_create_pub" { CREATE PUBLICATION pub FOR ALL TABLES WITH 
(publish_via_partition_root = true); }
+step "s1_insert_part" { INSERT INTO tbl_part_p1 VALUES (1); }
 step "s1_commit" { COMMIT; }
 
 session "s2"
@@ -43,8 +53,10 @@ step "s2_alter_tbl2_drop_3rd_col" { ALTER TABLE tbl2 DROP 
COLUMN val3; }
 step "s2_alter_tbl2_3rd_char" { ALTER TABLE tbl2 ALTER COLUMN val3 TYPE 
character varying; }
 step "s2_alter_tbl2_3rd_text" { ALTER TABLE tbl2 ALTER COLUMN val3 TYPE text; }
 step "s2_alter_tbl2_3rd_int" { ALTER TABLE tbl2 ALTER COLUMN val3 TYPE int 
USING val3::integer; }
+step "s2_detach_part" { ALTER TABLE tbl_part DETACH PARTITION tbl_part_p1 
CONCURRENTLY; }
 
 step "s2_get_changes" { SELECT data FROM 
pg_logical_slot_get_changes('isolation_slot', NULL, NULL, 'include-xids', '0', 
'skip-empty-xacts', '1'); }
+step "s2_get_binary_changes" { SELECT count(*) > 0 FROM 
pg_logical_slot_get_binary_changes('isolation_slot', NULL, NULL, 
'proto_version', '1', 'publication_names', 'pub'); }
 
 
 
@@ -86,3 +98,8 @@ permutation "s1_init" "s2_alter_tbl2_add_char" "s1_begin" 
"s1_insert_tbl1" "s2_a
 permutation "s1_init" "s2_alter_tbl2_add_text" "s1_begin" "s1_insert_tbl1" 
"s2_alter_tbl2_3rd_char" "s1_insert_tbl2_3col" "s1_commit" 
"s2_alter_tbl2_drop_3rd_col" "s1_insert_tbl2" "s2_get_changes"
 
 permutation "s1_init" "s2_alter_tbl2_add_char" "s1_begin" "s1_insert_tbl1" 
"s2_alter_tbl2_drop_3rd_col" "s1_insert_tbl1" "s1_commit" "s2_get_changes"
+
+# Verify that pgoutput can decode changes to a partition whose concurrent
+# detach was canceled, when the relation is still marked as a partition in
+# pg_class but already reports no ancestors.
+permutation "s1_init_pgoutput" "s1_begin" "s1_select_part" "s2_detach_part" 
"s1_cancel_s2"("s2_detach_part") "s1_commit" "s1_create_pub" "s1_insert_part" 
"s2_get_binary_changes"
diff --git a/src/backend/catalog/partition.c b/src/backend/catalog/partition.c
index f8780ce57da..86d964fc233 100644
--- a/src/backend/catalog/partition.c
+++ b/src/backend/catalog/partition.c
@@ -125,6 +125,11 @@ get_partition_parent_worker(Relation inhRel, Oid relid, 
bool *detach_pending)
  *
  * Returns a list of ancestors of the given relation.
  *
+ * A partition pending concurrent detach is treated as having no parent. The
+ * list can therefore be empty even if the relation is still marked as a
+ * partition in pg_class. If an ancestor is pending detach, the list ends at
+ * that ancestor.
+ *
  * Note: Because this function assumes that the relation whose OID is passed
  * as an argument and each ancestor will have precisely one parent, it should
  * only be called when it is known that the relation is a partition.
diff --git a/src/backend/replication/pgoutput/pgoutput.c 
b/src/backend/replication/pgoutput/pgoutput.c
index ad13f58e649..565775431d8 100644
--- a/src/backend/replication/pgoutput/pgoutput.c
+++ b/src/backend/replication/pgoutput/pgoutput.c
@@ -2049,9 +2049,19 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
                ListCell   *lc;
                Oid                     publish_as_relid = relid;
                int                     publish_ancestor_level = 0;
-               bool            am_partition = get_rel_relispartition(relid);
                char            relkind = get_rel_relkind(relid);
                List       *rel_publications = NIL;
+               List       *ancestors = NIL;
+
+               /*
+                * A partition whose concurrent detach has been committed but 
not
+                * finalized reports no ancestors, even though relispartition 
is still
+                * set. Determine publication membership and identity as for a
+                * standalone table: changes are no longer published via its 
former
+                * ancestors, but can still be published via its own 
publications.
+                */
+               if (get_rel_relispartition(relid))
+                       ancestors = get_partition_ancestors(relid);
 
                /* Reload publications if needed before use. */
                if (!publications_valid)
@@ -2157,10 +2167,8 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
                        if (pub->alltables)
                        {
                                publish = true;
-                               if (pub->pubviaroot && am_partition)
+                               if (pub->pubviaroot && ancestors)
                                {
-                                       List       *ancestors = 
get_partition_ancestors(relid);
-
                                        pub_relid = llast_oid(ancestors);
                                        ancestor_level = list_length(ancestors);
                                }
@@ -2176,11 +2184,10 @@ get_rel_sync_entry(PGOutputData *data, Relation 
relation)
                                 * published via this publication, which will 
be used as the
                                 * relation via which to publish the 
partition's changes.
                                 */
-                               if (am_partition)
+                               if (ancestors)
                                {
                                        Oid                     ancestor;
                                        int                     level;
-                                       List       *ancestors = 
get_partition_ancestors(relid);
 
                                        ancestor = 
GetTopMostAncestorInPublication(pub->oid,
                                                                                
                                           ancestors,
@@ -2275,6 +2282,7 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
                list_free(pubids);
                list_free(schemaPubids);
                list_free(rel_publications);
+               list_free(ancestors);
 
                entry->replicate_valid = true;
        }
-- 
2.54.0 (Apple Git-157)

From e4b367d6c8c476b20dc54599d5fd1beab5707e02 Mon Sep 17 00:00:00 2001
From: Mikhail Nikalayeu <[email protected]>
Date: Wed, 11 Nov 2026 19:21:58 +0800
Subject: [PATCH v3] Fix crash decoding a change to a partition pending detach.
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

ALTER TABLE ... DETACH PARTITION ... CONCURRENTLY, added by 71f4c8c6f74b,
leaves the partition marked as detaching when its wait is interrupted -- by
a lock timeout, a cancel, a disconnect. Only DETACH PARTITION ... FINALIZE
clears that mark, and the partition cannot be attached back. In that state
pg_class still says relispartition, while get_partition_ancestors() already
reports nothing.

get_rel_sync_entry() was not ready for it: it read relispartition as meaning
that the ancestor list is not empty, and asked for its last element, which is
an assertion failure, and a NULL pointer dereference without assertions.
Decoding any change to such a partition for a FOR ALL TABLES publication gets
there, and as the change is never confirmed, a subscription turns that into a
crash loop.

Fetch the ancestors once, up front, and treat a partition with no ancestors
as a standalone table, which is how it is published once the detach
completes.

The crash became reachable in v14, where 71f4c8c6f74b introduced the state,
and fd366065e06a widened it from a publication with
publish_via_partition_root to any FOR ALL TABLES one.

Reported-by: Mikhail Nikalayeu <[email protected]>
Reported-by: Nisha Moond <[email protected]>
Reported-by: Zsolt Parragi <[email protected]>
Author: Mikhail Nikalayeu <[email protected]>
Author: Zhijie Hou <[email protected]>
Reviewed-by: Álvaro Herrera <[email protected]>
Reviewed-by: Nisha Moond <[email protected]>
Reviewed-by: shveta malik <[email protected]>
Reviewed-by: Amit Kapila <[email protected]>
Reviewed-by: Masahiko Sawada <[email protected]>
Reviewed-by: Zhijie Hou <[email protected]>
Discussion: 
https://postgr.es/m/CADzfLwWoFPT%2Ba73%3DA%3DbsNWRMZQ98NpBEMgE%3Dt1FS4O4_%3DQVLfA%40mail.gmail.com
Discussion: 
https://postgr.es/m/CABdArM64ZSat%3DdB094U8OCBzp1m8_Y85m6T%2BxPEgpLVJb60_rA%40mail.gmail.com
Backpatch-through: 15
---
 .../expected/concurrent_ddl_dml.out           | 38 +++++++++++++++++++
 .../specs/concurrent_ddl_dml.spec             | 17 +++++++++
 src/backend/catalog/partition.c               |  5 +++
 src/backend/replication/pgoutput/pgoutput.c   | 20 +++++++---
 4 files changed, 74 insertions(+), 6 deletions(-)

diff --git a/contrib/test_decoding/expected/concurrent_ddl_dml.out 
b/contrib/test_decoding/expected/concurrent_ddl_dml.out
index 3742a2a2474..a09776fea8a 100644
--- a/contrib/test_decoding/expected/concurrent_ddl_dml.out
+++ b/contrib/test_decoding/expected/concurrent_ddl_dml.out
@@ -797,3 +797,41 @@ COMMIT
 stop    
 (1 row)
 
+
+starting permutation: s1_init_pgoutput s1_begin s1_select_part s2_detach_part 
s1_cancel_s2 s1_commit s1_create_pub s1_insert_part s2_get_binary_changes
+step s1_init_pgoutput: SELECT 'init' FROM 
pg_create_logical_replication_slot('isolation_slot', 'pgoutput');
+?column?
+--------
+init    
+(1 row)
+
+step s1_begin: BEGIN;
+step s1_select_part: SELECT * FROM tbl_part;
+val1
+----
+(0 rows)
+
+step s2_detach_part: ALTER TABLE tbl_part DETACH PARTITION tbl_part_p1 
CONCURRENTLY; <waiting ...>
+step s1_cancel_s2: SELECT pg_cancel_backend(pid) FROM pg_stat_activity WHERE 
application_name = 'isolation/concurrent_ddl_dml/s2'; <waiting ...>
+step s2_detach_part: <... completed>
+ERROR:  canceling statement due to user request
+step s1_cancel_s2: <... completed>
+pg_cancel_backend
+-----------------
+t                
+(1 row)
+
+step s1_commit: COMMIT;
+step s1_create_pub: CREATE PUBLICATION pub FOR ALL TABLES WITH 
(publish_via_partition_root = true);
+step s1_insert_part: INSERT INTO tbl_part_p1 VALUES (1);
+step s2_get_binary_changes: SELECT count(*) > 0 FROM 
pg_logical_slot_get_binary_changes('isolation_slot', NULL, NULL, 
'proto_version', '1', 'publication_names', 'pub');
+?column?
+--------
+t       
+(1 row)
+
+?column?
+--------
+stop    
+(1 row)
+
diff --git a/contrib/test_decoding/specs/concurrent_ddl_dml.spec 
b/contrib/test_decoding/specs/concurrent_ddl_dml.spec
index d16515e6f48..77ec8c133a5 100644
--- a/contrib/test_decoding/specs/concurrent_ddl_dml.spec
+++ b/contrib/test_decoding/specs/concurrent_ddl_dml.spec
@@ -2,12 +2,17 @@ setup
 {
     DROP TABLE IF EXISTS tbl1;
     DROP TABLE IF EXISTS tbl2;
+    DROP TABLE IF EXISTS tbl_part, tbl_part_p1;
     CREATE TABLE tbl1(val1 integer, val2 integer);
     CREATE TABLE tbl2(val1 integer, val2 integer);
+    CREATE TABLE tbl_part(val1 integer) PARTITION BY LIST (val1);
+    CREATE TABLE tbl_part_p1 PARTITION OF tbl_part FOR VALUES IN (1);
 }
 
 teardown
 {
+    DROP PUBLICATION IF EXISTS pub;
+    DROP TABLE IF EXISTS tbl_part, tbl_part_p1;
     DROP TABLE tbl1;
     DROP TABLE tbl2;
     SELECT 'stop' FROM pg_drop_replication_slot('isolation_slot');
@@ -17,10 +22,15 @@ session "s1"
 setup { SET synchronous_commit=on; }
 
 step "s1_init" { SELECT 'init' FROM 
pg_create_logical_replication_slot('isolation_slot', 'test_decoding'); }
+step "s1_init_pgoutput" { SELECT 'init' FROM 
pg_create_logical_replication_slot('isolation_slot', 'pgoutput'); }
 step "s1_begin" { BEGIN; }
 step "s1_insert_tbl1" { INSERT INTO tbl1 (val1, val2) VALUES (1, 1); }
 step "s1_insert_tbl2" { INSERT INTO tbl2 (val1, val2) VALUES (1, 1); }
 step "s1_insert_tbl2_3col" { INSERT INTO tbl2 (val1, val2, val3) VALUES (1, 1, 
1); }
+step "s1_select_part" { SELECT * FROM tbl_part; }
+step "s1_cancel_s2" { SELECT pg_cancel_backend(pid) FROM pg_stat_activity 
WHERE application_name = 'isolation/concurrent_ddl_dml/s2'; }
+step "s1_create_pub" { CREATE PUBLICATION pub FOR ALL TABLES WITH 
(publish_via_partition_root = true); }
+step "s1_insert_part" { INSERT INTO tbl_part_p1 VALUES (1); }
 step "s1_commit" { COMMIT; }
 
 session "s2"
@@ -43,8 +53,10 @@ step "s2_alter_tbl2_drop_3rd_col" { ALTER TABLE tbl2 DROP 
COLUMN val3; }
 step "s2_alter_tbl2_3rd_char" { ALTER TABLE tbl2 ALTER COLUMN val3 TYPE 
character varying; }
 step "s2_alter_tbl2_3rd_text" { ALTER TABLE tbl2 ALTER COLUMN val3 TYPE text; }
 step "s2_alter_tbl2_3rd_int" { ALTER TABLE tbl2 ALTER COLUMN val3 TYPE int 
USING val3::integer; }
+step "s2_detach_part" { ALTER TABLE tbl_part DETACH PARTITION tbl_part_p1 
CONCURRENTLY; }
 
 step "s2_get_changes" { SELECT data FROM 
pg_logical_slot_get_changes('isolation_slot', NULL, NULL, 'include-xids', '0', 
'skip-empty-xacts', '1'); }
+step "s2_get_binary_changes" { SELECT count(*) > 0 FROM 
pg_logical_slot_get_binary_changes('isolation_slot', NULL, NULL, 
'proto_version', '1', 'publication_names', 'pub'); }
 
 
 
@@ -86,3 +98,8 @@ permutation "s1_init" "s2_alter_tbl2_add_char" "s1_begin" 
"s1_insert_tbl1" "s2_a
 permutation "s1_init" "s2_alter_tbl2_add_text" "s1_begin" "s1_insert_tbl1" 
"s2_alter_tbl2_3rd_char" "s1_insert_tbl2_3col" "s1_commit" 
"s2_alter_tbl2_drop_3rd_col" "s1_insert_tbl2" "s2_get_changes"
 
 permutation "s1_init" "s2_alter_tbl2_add_char" "s1_begin" "s1_insert_tbl1" 
"s2_alter_tbl2_drop_3rd_col" "s1_insert_tbl1" "s1_commit" "s2_get_changes"
+
+# Verify that pgoutput can decode changes to a partition whose concurrent
+# detach was canceled, when the relation is still marked as a partition in
+# pg_class but already reports no ancestors.
+permutation "s1_init_pgoutput" "s1_begin" "s1_select_part" "s2_detach_part" 
"s1_cancel_s2"("s2_detach_part") "s1_commit" "s1_create_pub" "s1_insert_part" 
"s2_get_binary_changes"
diff --git a/src/backend/catalog/partition.c b/src/backend/catalog/partition.c
index 93d72157a46..c8e116bd1fb 100644
--- a/src/backend/catalog/partition.c
+++ b/src/backend/catalog/partition.c
@@ -126,6 +126,11 @@ get_partition_parent_worker(Relation inhRel, Oid relid, 
bool *detach_pending)
  * The first element is the immediate parent and the last one is the topmost
  * parent in the partition hierarchy.
  *
+ * A partition pending concurrent detach is treated as having no parent. The
+ * list can therefore be empty even if the relation is still marked as a
+ * partition in pg_class. If an ancestor is pending detach, the list ends at
+ * that ancestor.
+ *
  * Note: Because this function assumes that the relation whose OID is passed
  * as an argument and each ancestor will have precisely one parent, it should
  * only be called when it is known that the relation is a partition.
diff --git a/src/backend/replication/pgoutput/pgoutput.c 
b/src/backend/replication/pgoutput/pgoutput.c
index 7eed467edd5..e9781da17b1 100644
--- a/src/backend/replication/pgoutput/pgoutput.c
+++ b/src/backend/replication/pgoutput/pgoutput.c
@@ -2100,9 +2100,19 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
                ListCell   *lc;
                Oid                     publish_as_relid = relid;
                int                     publish_ancestor_level = 0;
-               bool            am_partition = get_rel_relispartition(relid);
                char            relkind = get_rel_relkind(relid);
                List       *rel_publications = NIL;
+               List       *ancestors = NIL;
+
+               /*
+                * A partition whose concurrent detach has been committed but 
not
+                * finalized reports no ancestors, even though relispartition 
is still
+                * set. Determine publication membership and identity as for a
+                * standalone table: changes are no longer published via its 
former
+                * ancestors, but can still be published via its own 
publications.
+                */
+               if (get_rel_relispartition(relid))
+                       ancestors = get_partition_ancestors(relid);
 
                /* Reload publications if needed before use. */
                if (!publications_valid)
@@ -2207,10 +2217,8 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
                        if (pub->alltables)
                        {
                                publish = true;
-                               if (pub->pubviaroot && am_partition)
+                               if (pub->pubviaroot && ancestors)
                                {
-                                       List       *ancestors = 
get_partition_ancestors(relid);
-
                                        pub_relid = llast_oid(ancestors);
                                        ancestor_level = list_length(ancestors);
                                }
@@ -2226,11 +2234,10 @@ get_rel_sync_entry(PGOutputData *data, Relation 
relation)
                                 * published via this publication, which will 
be used as the
                                 * relation via which to publish the 
partition's changes.
                                 */
-                               if (am_partition)
+                               if (ancestors)
                                {
                                        Oid                     ancestor;
                                        int                     level;
-                                       List       *ancestors = 
get_partition_ancestors(relid);
 
                                        ancestor = 
GetTopMostAncestorInPublication(pub->oid,
                                                                                
                                           ancestors,
@@ -2328,6 +2335,7 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
                list_free(pubids);
                list_free(schemaPubids);
                list_free(rel_publications);
+               list_free(ancestors);
 
                entry->replicate_valid = true;
        }
-- 
2.54.0 (Apple Git-157)

From 55b99bc32f569530d79f5f5912df348170c7ffa0 Mon Sep 17 00:00:00 2001
From: Mikhail Nikalayeu <[email protected]>
Date: Wed, 11 Nov 2026 21:28:52 +0800
Subject: [PATCH v3] Fix crash decoding a change to a partition pending detach.
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

ALTER TABLE ... DETACH PARTITION ... CONCURRENTLY, added by 71f4c8c6f74b,
leaves the partition marked as detaching when its wait is interrupted -- by
a lock timeout, a cancel, a disconnect. Only DETACH PARTITION ... FINALIZE
clears that mark, and the partition cannot be attached back. In that state
pg_class still says relispartition, while get_partition_ancestors() already
reports nothing.

get_rel_sync_entry() was not ready for it: it read relispartition as meaning
that the ancestor list is not empty, and asked for its last element, which is
an assertion failure, and a NULL pointer dereference without assertions.
Decoding any change to such a partition for a FOR ALL TABLES publication gets
there, and as the change is never confirmed, a subscription turns that into a
crash loop.

Fetch the ancestors once, up front, and treat a partition with no ancestors
as a standalone table, which is how it is published once the detach
completes.

The crash became reachable in v14, where 71f4c8c6f74b introduced the state,
and fd366065e06a widened it from a publication with
publish_via_partition_root to any FOR ALL TABLES one.

Reported-by: Mikhail Nikalayeu <[email protected]>
Reported-by: Nisha Moond <[email protected]>
Reported-by: Zsolt Parragi <[email protected]>
Author: Mikhail Nikalayeu <[email protected]>
Author: Zhijie Hou <[email protected]>
Reviewed-by: Álvaro Herrera <[email protected]>
Reviewed-by: Nisha Moond <[email protected]>
Reviewed-by: shveta malik <[email protected]>
Reviewed-by: Amit Kapila <[email protected]>
Reviewed-by: Masahiko Sawada <[email protected]>
Reviewed-by: Zhijie Hou <[email protected]>
Discussion: 
https://postgr.es/m/CADzfLwWoFPT%2Ba73%3DA%3DbsNWRMZQ98NpBEMgE%3Dt1FS4O4_%3DQVLfA%40mail.gmail.com
Discussion: 
https://postgr.es/m/CABdArM64ZSat%3DdB094U8OCBzp1m8_Y85m6T%2BxPEgpLVJb60_rA%40mail.gmail.com
Backpatch-through: 15
---
 .../expected/concurrent_ddl_dml.out           | 38 +++++++++++++++++++
 .../specs/concurrent_ddl_dml.spec             | 17 +++++++++
 src/backend/catalog/partition.c               |  5 +++
 src/backend/replication/pgoutput/pgoutput.c   | 19 +++++++---
 4 files changed, 74 insertions(+), 5 deletions(-)

diff --git a/contrib/test_decoding/expected/concurrent_ddl_dml.out 
b/contrib/test_decoding/expected/concurrent_ddl_dml.out
index 3742a2a2474..4dddf045071 100644
--- a/contrib/test_decoding/expected/concurrent_ddl_dml.out
+++ b/contrib/test_decoding/expected/concurrent_ddl_dml.out
@@ -797,3 +797,41 @@ COMMIT
 stop    
 (1 row)
 
+
+starting permutation: s1_init_pgoutput s1_begin s1_select_part s2_detach_part 
s1_cancel_s2 s1_commit s1_create_pub s1_insert_part s2_get_binary_changes
+step s1_init_pgoutput: SELECT 'init' FROM 
pg_create_logical_replication_slot('isolation_slot', 'pgoutput');
+?column?
+--------
+init    
+(1 row)
+
+step s1_begin: BEGIN;
+step s1_select_part: SELECT * FROM tbl_part;
+val1
+----
+(0 rows)
+
+step s2_detach_part: ALTER TABLE tbl_part DETACH PARTITION tbl_part_p1 
CONCURRENTLY; <waiting ...>
+step s1_cancel_s2: SELECT pg_cancel_backend(pid) FROM pg_stat_activity WHERE 
application_name = 'isolation/concurrent_ddl_dml/s2'; <waiting ...>
+step s2_detach_part: <... completed>
+ERROR:  canceling statement due to user request
+step s1_cancel_s2: <... completed>
+pg_cancel_backend
+-----------------
+t                
+(1 row)
+
+step s1_commit: COMMIT;
+step s1_create_pub: CREATE PUBLICATION pub FOR ALL TABLES;
+step s1_insert_part: INSERT INTO tbl_part_p1 VALUES (1);
+step s2_get_binary_changes: SELECT count(*) > 0 FROM 
pg_logical_slot_get_binary_changes('isolation_slot', NULL, NULL, 
'proto_version', '1', 'publication_names', 'pub');
+?column?
+--------
+t       
+(1 row)
+
+?column?
+--------
+stop    
+(1 row)
+
diff --git a/contrib/test_decoding/specs/concurrent_ddl_dml.spec 
b/contrib/test_decoding/specs/concurrent_ddl_dml.spec
index d16515e6f48..e33a614c2e0 100644
--- a/contrib/test_decoding/specs/concurrent_ddl_dml.spec
+++ b/contrib/test_decoding/specs/concurrent_ddl_dml.spec
@@ -2,12 +2,17 @@ setup
 {
     DROP TABLE IF EXISTS tbl1;
     DROP TABLE IF EXISTS tbl2;
+    DROP TABLE IF EXISTS tbl_part, tbl_part_p1;
     CREATE TABLE tbl1(val1 integer, val2 integer);
     CREATE TABLE tbl2(val1 integer, val2 integer);
+    CREATE TABLE tbl_part(val1 integer) PARTITION BY LIST (val1);
+    CREATE TABLE tbl_part_p1 PARTITION OF tbl_part FOR VALUES IN (1);
 }
 
 teardown
 {
+    DROP PUBLICATION IF EXISTS pub;
+    DROP TABLE IF EXISTS tbl_part, tbl_part_p1;
     DROP TABLE tbl1;
     DROP TABLE tbl2;
     SELECT 'stop' FROM pg_drop_replication_slot('isolation_slot');
@@ -17,10 +22,15 @@ session "s1"
 setup { SET synchronous_commit=on; }
 
 step "s1_init" { SELECT 'init' FROM 
pg_create_logical_replication_slot('isolation_slot', 'test_decoding'); }
+step "s1_init_pgoutput" { SELECT 'init' FROM 
pg_create_logical_replication_slot('isolation_slot', 'pgoutput'); }
 step "s1_begin" { BEGIN; }
 step "s1_insert_tbl1" { INSERT INTO tbl1 (val1, val2) VALUES (1, 1); }
 step "s1_insert_tbl2" { INSERT INTO tbl2 (val1, val2) VALUES (1, 1); }
 step "s1_insert_tbl2_3col" { INSERT INTO tbl2 (val1, val2, val3) VALUES (1, 1, 
1); }
+step "s1_select_part" { SELECT * FROM tbl_part; }
+step "s1_cancel_s2" { SELECT pg_cancel_backend(pid) FROM pg_stat_activity 
WHERE application_name = 'isolation/concurrent_ddl_dml/s2'; }
+step "s1_create_pub" { CREATE PUBLICATION pub FOR ALL TABLES; }
+step "s1_insert_part" { INSERT INTO tbl_part_p1 VALUES (1); }
 step "s1_commit" { COMMIT; }
 
 session "s2"
@@ -43,8 +53,10 @@ step "s2_alter_tbl2_drop_3rd_col" { ALTER TABLE tbl2 DROP 
COLUMN val3; }
 step "s2_alter_tbl2_3rd_char" { ALTER TABLE tbl2 ALTER COLUMN val3 TYPE 
character varying; }
 step "s2_alter_tbl2_3rd_text" { ALTER TABLE tbl2 ALTER COLUMN val3 TYPE text; }
 step "s2_alter_tbl2_3rd_int" { ALTER TABLE tbl2 ALTER COLUMN val3 TYPE int 
USING val3::integer; }
+step "s2_detach_part" { ALTER TABLE tbl_part DETACH PARTITION tbl_part_p1 
CONCURRENTLY; }
 
 step "s2_get_changes" { SELECT data FROM 
pg_logical_slot_get_changes('isolation_slot', NULL, NULL, 'include-xids', '0', 
'skip-empty-xacts', '1'); }
+step "s2_get_binary_changes" { SELECT count(*) > 0 FROM 
pg_logical_slot_get_binary_changes('isolation_slot', NULL, NULL, 
'proto_version', '1', 'publication_names', 'pub'); }
 
 
 
@@ -86,3 +98,8 @@ permutation "s1_init" "s2_alter_tbl2_add_char" "s1_begin" 
"s1_insert_tbl1" "s2_a
 permutation "s1_init" "s2_alter_tbl2_add_text" "s1_begin" "s1_insert_tbl1" 
"s2_alter_tbl2_3rd_char" "s1_insert_tbl2_3col" "s1_commit" 
"s2_alter_tbl2_drop_3rd_col" "s1_insert_tbl2" "s2_get_changes"
 
 permutation "s1_init" "s2_alter_tbl2_add_char" "s1_begin" "s1_insert_tbl1" 
"s2_alter_tbl2_drop_3rd_col" "s1_insert_tbl1" "s1_commit" "s2_get_changes"
+
+# Verify that pgoutput can decode changes to a partition whose concurrent
+# detach was canceled, when the relation is still marked as a partition in
+# pg_class but already reports no ancestors.
+permutation "s1_init_pgoutput" "s1_begin" "s1_select_part" "s2_detach_part" 
"s1_cancel_s2"("s2_detach_part") "s1_commit" "s1_create_pub" "s1_insert_part" 
"s2_get_binary_changes"
diff --git a/src/backend/catalog/partition.c b/src/backend/catalog/partition.c
index 28f3cade6ff..5efa7d7fa84 100644
--- a/src/backend/catalog/partition.c
+++ b/src/backend/catalog/partition.c
@@ -126,6 +126,11 @@ get_partition_parent_worker(Relation inhRel, Oid relid, 
bool *detach_pending)
  * The first element is the immediate parent and the last one is the topmost
  * parent in the partition hierarchy.
  *
+ * A partition pending concurrent detach is treated as having no parent. The
+ * list can therefore be empty even if the relation is still marked as a
+ * partition in pg_class. If an ancestor is pending detach, the list ends at
+ * that ancestor.
+ *
  * Note: Because this function assumes that the relation whose OID is passed
  * as an argument and each ancestor will have precisely one parent, it should
  * only be called when it is known that the relation is a partition.
diff --git a/src/backend/replication/pgoutput/pgoutput.c 
b/src/backend/replication/pgoutput/pgoutput.c
index c8caa163c15..0af841c636c 100644
--- a/src/backend/replication/pgoutput/pgoutput.c
+++ b/src/backend/replication/pgoutput/pgoutput.c
@@ -2103,9 +2103,19 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
                ListCell   *lc;
                Oid                     publish_as_relid = relid;
                int                     publish_ancestor_level = 0;
-               bool            am_partition = get_rel_relispartition(relid);
                char            relkind = get_rel_relkind(relid);
                List       *rel_publications = NIL;
+               List       *ancestors = NIL;
+
+               /*
+                * A partition whose concurrent detach has been committed but 
not
+                * finalized reports no ancestors, even though relispartition 
is still
+                * set. Determine publication membership and identity as for a
+                * standalone table: changes are no longer published via its 
former
+                * ancestors, but can still be published via its own 
publications.
+                */
+               if (get_rel_relispartition(relid))
+                       ancestors = get_partition_ancestors(relid);
 
                /* Reload publications if needed before use. */
                if (!publications_valid)
@@ -2211,9 +2221,8 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
                        {
                                List       *exceptpubids = NIL;
 
-                               if (am_partition)
+                               if (ancestors)
                                {
-                                       List       *ancestors = 
get_partition_ancestors(relid);
                                        Oid                     
last_ancestor_relid = llast_oid(ancestors);
 
                                        /*
@@ -2262,11 +2271,10 @@ get_rel_sync_entry(PGOutputData *data, Relation 
relation)
                                 * published via this publication, which will 
be used as the
                                 * relation via which to publish the 
partition's changes.
                                 */
-                               if (am_partition)
+                               if (ancestors)
                                {
                                        Oid                     ancestor;
                                        int                     level;
-                                       List       *ancestors = 
get_partition_ancestors(relid);
 
                                        ancestor = 
GetTopMostAncestorInPublication(pub->oid,
                                                                                
                                           ancestors,
@@ -2364,6 +2372,7 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
                list_free(pubids);
                list_free(schemaPubids);
                list_free(rel_publications);
+               list_free(ancestors);
 
                entry->replicate_valid = true;
        }
-- 
2.54.0 (Apple Git-157)

Reply via email to